diff --git a/src/main/java/org/apache/commons/io/file/CopyDirectoryVisitor.java b/src/main/java/org/apache/commons/io/file/CopyDirectoryVisitor.java index 45b761c971c..8a63ef126be 100644 --- a/src/main/java/org/apache/commons/io/file/CopyDirectoryVisitor.java +++ b/src/main/java/org/apache/commons/io/file/CopyDirectoryVisitor.java @@ -28,6 +28,7 @@ import java.util.Objects; import org.apache.commons.io.file.Counters.PathCounters; +import org.apache.commons.io.filefilter.TrueFileFilter; /** * Copies a source directory to a target directory. @@ -53,7 +54,7 @@ private static CopyOption[] toCopyOption(final CopyOption... copyOptions) { * @param copyOptions Specifies how the copying should be done. */ public CopyDirectoryVisitor(final PathCounters pathCounter, final Path sourceDirectory, final Path targetDirectory, final CopyOption... copyOptions) { - super(pathCounter); + super(pathCounter, TrueFileFilter.INSTANCE, TrueFileFilter.INSTANCE); this.sourceDirectory = sourceDirectory; this.targetDirectory = targetDirectory; this.copyOptions = toCopyOption(copyOptions); diff --git a/src/main/java/org/apache/commons/io/file/CountingPathVisitor.java b/src/main/java/org/apache/commons/io/file/CountingPathVisitor.java index ab02ad073e2..c8c3bb2ddff 100644 --- a/src/main/java/org/apache/commons/io/file/CountingPathVisitor.java +++ b/src/main/java/org/apache/commons/io/file/CountingPathVisitor.java @@ -311,7 +311,14 @@ protected void updateDirCounter(final Path dir, final IOException exc) { */ protected void updateFileCounters(final Path file, final BasicFileAttributes attributes) { pathCounters.getFileCounter().increment(); - pathCounters.getByteCounter().add(attributes.size()); + // According to the JavaDoc, BasicFileAttributes.size() is only well-defined for regular files. + // For symbolic links on Linux for example, it counts the # (charset dependent?) bytes in the inode name, which is NOT what we want to count here. + // Intuitively, the appropriate check would be Files.isRegularFile. + // However, for symbolic links, isRegularFile returns true under a "follow links" regime. + // That would still not give us what we want, so instead we settle for a !Files.isSymbolicLink check. + if (!Files.isSymbolicLink(file)) { + pathCounters.getByteCounter().add(attributes.size()); + } } @Override diff --git a/src/main/java/org/apache/commons/io/file/PathUtils.java b/src/main/java/org/apache/commons/io/file/PathUtils.java index 6a9718c4c07..27b52a93fd5 100644 --- a/src/main/java/org/apache/commons/io/file/PathUtils.java +++ b/src/main/java/org/apache/commons/io/file/PathUtils.java @@ -227,6 +227,8 @@ private RelativeSortedPaths(final Path dir1, final Path dir2, final int maxDepth */ public static final FileVisitOption[] EMPTY_FILE_VISIT_OPTION_ARRAY = {}; + private static final Set FOLLOW_LINKS_FILE_VISIT_OPTIONS = EnumSet.of(FileVisitOption.FOLLOW_LINKS); + /** * Empty {@link LinkOption} array. */ @@ -368,6 +370,27 @@ public static long copy(final IOSupplier in, final Path target, fin /** * Copies a directory to another directory. + * Symbolic links are either followed or copied, depending on the {@link LinkOption#NOFOLLOW_LINKS} option. + * Non-symbolic links, aka. hard links, are always copied, given that they appear as regular files to Java. + * {@code LinkOption} does not apply to hard links. + * Symbolic links can link to files or directories, while non-symbolic links can only link to files. + * Symbolic links can be (ab)used to create endlessly recursive directories. + * + *

Without {@link LinkOption#NOFOLLOW_LINKS} option (the default)

+ * Given that Java defines {@link LinkOption#NOFOLLOW_LINKS} as an explicit option, the default is the absence of that option, which is to follow links. + * Symbolic links in the source directory are followed, resulting in a target directory that has no symbolic links. + * Cyclic symbolic links cause the copy operation to abort and throw a {@link java.nio.file.FileSystemLoopException}. + * Broken symbolic links are ignored, they are not copied. + * + *

With {@link LinkOption#NOFOLLOW_LINKS} option

+ * Symbolic links in the source directory are copied to the target directory as symbolic links. + * Symbolic links linking inside the source directory are copied as relative links, meaning that the target symbolic + * link will link inside the target directory to a copied file or directory. + * Symbolic links linking outside the source directory are copied as absolute links, meaning that the target symbolic + * link will link outside the target directory to the same file or directory the link is linking to in the source directory. + * Cyclic symbolic links are preserved as regular symbolic links. + * Their cyclic nature is irrelevant to the copy operation. + * Broken symbolic links are ignored, they are not copied. * * @param sourceDirectory The source directory. * @param targetDirectory The target directory. @@ -377,7 +400,22 @@ public static long copy(final IOSupplier in, final Path target, fin */ public static PathCounters copyDirectory(final Path sourceDirectory, final Path targetDirectory, final CopyOption... copyOptions) throws IOException { final Path absoluteSource = sourceDirectory.toAbsolutePath(); - return visitFileTree(new CopyDirectoryVisitor(Counters.longPathCounters(), absoluteSource, targetDirectory, copyOptions), absoluteSource) + // CopyOption.NOFOLLOW_LINKS is the explicit option, "follow symlinks" the implicit default. + // For FileVisitOption it's the other way around: FileVisitOption.FOLLOW_LINKS is the explicit option, and "do not follow symlinks" is the implicit + // default. + // If they're not in sync, the behavior is inconsistent, so we have to make sure they're in sync. + // CopyOption is given by the caller, FileVisitOption is under our control here, so we sync the latter to the former. + Set fileVisitOptions = FOLLOW_LINKS_FILE_VISIT_OPTIONS; + if (copyOptions != null) { + for (CopyOption copyOption : copyOptions) { + if (LinkOption.NOFOLLOW_LINKS.equals(copyOption)) { + fileVisitOptions = Collections.emptySet(); + break; + } + } + } + return visitFileTree(new CopyDirectoryVisitor(Counters.longPathCounters(), absoluteSource, targetDirectory, copyOptions), absoluteSource, + fileVisitOptions, Integer.MAX_VALUE) .getPathCounters(); } diff --git a/src/test/java/org/apache/commons/io/file/PathUtilsTest.java b/src/test/java/org/apache/commons/io/file/PathUtilsTest.java index cdf4360acc6..e603d038474 100644 --- a/src/test/java/org/apache/commons/io/file/PathUtilsTest.java +++ b/src/test/java/org/apache/commons/io/file/PathUtilsTest.java @@ -17,6 +17,7 @@ package org.apache.commons.io.file; +import static java.nio.file.LinkOption.NOFOLLOW_LINKS; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -32,7 +33,9 @@ import java.io.OutputStream; import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; +import java.nio.file.CopyOption; import java.nio.file.DirectoryStream; +import java.nio.file.FileSystemLoopException; import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.LinkOption; @@ -43,8 +46,10 @@ import java.nio.file.attribute.PosixFileAttributes; import java.util.GregorianCalendar; import java.util.Iterator; +import java.util.stream.Stream; import org.apache.commons.io.FileUtils; +import org.apache.commons.io.file.Counters.PathCounters; import org.apache.commons.io.filefilter.NameFileFilter; import org.apache.commons.io.test.TestUtils; import org.apache.commons.lang3.ArrayUtils; @@ -52,6 +57,12 @@ import org.apache.commons.lang3.SystemProperties; import org.apache.commons.lang3.SystemUtils; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.junit.jupiter.params.support.ParameterDeclarations; /** * Tests {@link PathUtils}. @@ -496,4 +507,282 @@ private Path writeToNewOutputStream(final boolean append) throws IOException { return file; } + /** + * Illustrates how copy with {@link LinkOption#NOFOLLOW_LINKS} preserves relative symlinks to directories. + * This simulates to the behavior of Linux {@code cp -r}. + * Given the source directory structure: + *
{@code
+     * user@host:/tmp$ tree source/
+     * source/
+     * ├── dir1
+     * │   └── symlink -> ../dir2
+     * └── dir2
+     * }
+ * When doing {@code user@host:/tmp$ cp -r source target}, then the resulting target directory structure is: + *
{@code
+     * user@host:/tmp$ tree target/
+     * target/
+     * ├── dir1
+     * │   └── symlink -> ../dir2
+     * └── dir2
+     * }
+ */ + @Test + void testCopyDirectoryWithNoFollowLinksPreservesRelativeSymbolicLinkToDir() throws Exception { + // Given + final Path sourceDir = Files.createDirectory(tempDirPath.resolve("source")); + final Path dir1 = Files.createDirectory(sourceDir.resolve("dir1")); + final Path dir2 = Files.createDirectory(sourceDir.resolve("dir2")); + // source/dir1/symlink -> ../dir2 + Files.createSymbolicLink(dir1.resolve("symlink"), dir1.relativize(dir2)); + final Path targetDir = tempDirPath.resolve("target"); + + // When + final PathCounters pathCounters = PathUtils.copyDirectory(sourceDir, targetDir, NOFOLLOW_LINKS); + + // Then + assertEquals(0L, pathCounters.getByteCounter().get()); + assertEquals(3L, pathCounters.getDirectoryCounter().get()); + // Verify that symlink with NOFOLLOW_LINKS counts as file + assertEquals(1L, pathCounters.getFileCounter().get()); + final Path copyOfDir2 = targetDir.resolve("dir2"); + final Path copyOfRelativeSymlinkToDir2 = targetDir.resolve("dir1").resolve("symlink"); + assertTrue(Files.isSymbolicLink(copyOfRelativeSymlinkToDir2)); + assertTrue(Files.isDirectory(copyOfRelativeSymlinkToDir2)); + // Verify that target/dir1/symlink resolves to /tmp/target/dir2 + assertEquals(copyOfDir2.toRealPath(), copyOfRelativeSymlinkToDir2.toRealPath()); + } + + /** + * Illustrates how copy with {@link LinkOption#NOFOLLOW_LINKS} preserves absolute symlinks to directories. + * This simulates to the behavior of Linux {@code cp -r}. + * Given the source directory structure: + *
{@code
+     * user@host:/tmp$ tree source/ external/
+     * source/
+     * └── dir
+     *     └── symlink -> /tmp/external
+     * external/
+     * }
+ * When doing {@code user@host:/tmp$ cp -r source target}, then the resulting target directory structure is: + *
{@code
+     * user@host:/tmp$ tree target/
+     * target/
+     * └── dir
+     *     └── symlink -> /tmp/external
+     * }
+ */ + @Test + void testCopyDirectoryWithNoFollowLinksPreservesAbsoluteSymbolicLinkToDir() throws Exception { + // Given + final Path sourceDir = Files.createDirectory(tempDirPath.resolve("source")); + final Path externalDir = Files.createDirectory(tempDirPath.resolve("external")); + final Path dir = Files.createDirectory(sourceDir.resolve("dir")); + // source/dir/symlink -> /tmp/external + Files.createSymbolicLink(dir.resolve("symlink"), externalDir.toAbsolutePath()); + final Path targetDir = tempDirPath.resolve("target"); + + // When + final PathCounters pathCounters = PathUtils.copyDirectory(sourceDir, targetDir, NOFOLLOW_LINKS); + + // Then + assertEquals(0L, pathCounters.getByteCounter().get()); + assertEquals(2L, pathCounters.getDirectoryCounter().get()); + // Verify that symlink with NOFOLLOW_LINKS counts as file + assertEquals(1L, pathCounters.getFileCounter().get()); + final Path copyOfAbsoluteSymlinkToDir = targetDir.resolve("dir").resolve("symlink"); + assertTrue(Files.isSymbolicLink(copyOfAbsoluteSymlinkToDir)); + assertTrue(Files.isDirectory(copyOfAbsoluteSymlinkToDir)); + // Verify that target/dir/symlink resolves to /tmp/external + assertEquals(externalDir.toRealPath(), copyOfAbsoluteSymlinkToDir.toRealPath()); + } + + /** + * Illustrates how copy with {@link LinkOption#NOFOLLOW_LINKS} preserves relative symlinks to files. + * This simulates to the behavior of Linux {@code cp -r}. + * Given the source directory structure: + *
{@code
+     * user@host:/tmp$ tree source/
+     * source/
+     * ├── dir
+     * │   └── symlink -> ../file
+     * └── file
+     * }
+ * When doing {@code user@host:/tmp$ cp -r source target}, then the resulting target directory structure is: + *
{@code
+     * user@host:/tmp$ tree target/
+     * target/
+     * ├── dir
+     * │   └── symlink -> ../file
+     * └── file
+     * }
+ */ + @Test + void testCopyDirectoryWithNoFollowLinksPreservesRelativeSymbolicLinkToFile() throws Exception { + // Given + final Path sourceDir = Files.createDirectory(tempDirPath.resolve("source")); + final Path dir = Files.createDirectory(sourceDir.resolve("dir")); + final Path file = Files.write(sourceDir.resolve("file"), BYTE_ARRAY_FIXTURE); + // source/dir/symlink -> ../file + Files.createSymbolicLink(dir.resolve("symlink"), dir.relativize(file)); + final Path targetDir = tempDirPath.resolve("target"); + + // When + final PathCounters pathCounters = PathUtils.copyDirectory(sourceDir, targetDir, NOFOLLOW_LINKS); + + // Then + assertEquals(11L, pathCounters.getByteCounter().get()); + assertEquals(2L, pathCounters.getDirectoryCounter().get()); + // Verify that file + symlink with NOFOLLOW_LINKS counts as 2 files + assertEquals(2L, pathCounters.getFileCounter().get()); + final Path copyOfFile = targetDir.resolve("file"); + final Path copyOfRelativeSymlinkToFile = targetDir.resolve("dir").resolve("symlink"); + assertTrue(Files.isSymbolicLink(copyOfRelativeSymlinkToFile)); + assertTrue(Files.isRegularFile(copyOfRelativeSymlinkToFile)); + // Verify that /tmp/target/dir/symlink resolves to /tmp/target/file + assertEquals(copyOfFile.toRealPath(), copyOfRelativeSymlinkToFile.toRealPath()); + } + + /** + * Illustrates how copy with {@link LinkOption#NOFOLLOW_LINKS} preserves relative symlinks to files. + * This simulates to the behavior of Linux {@code cp -r}. + * Given the source directory structure: + *
{@code
+     * user@host:/tmp$ tree source/
+     * source/
+     * ├── dir
+     * │   └── symlink -> ../file
+     * └── file
+     * }
+ * When doing {@code user@host:/tmp$ cp -r source target}, then the resulting target directory structure is: + *
{@code
+     * user@host:/tmp$ tree target/
+     * target/
+     * ├── dir
+     * │   └── symlink -> ../file
+     * └── file
+     * }
+ */ + @Test + void testCopyDirectoryWithNoFollowLinksPreservesAbsoluteSymbolicLinkToFile() throws Exception { + // Given + final Path externalDir = Files.createDirectory(tempDirPath.resolve("external")); + final Path file = Files.write(externalDir.resolve("file"), BYTE_ARRAY_FIXTURE); + final Path sourceDir = Files.createDirectory(tempDirPath.resolve("source")); + final Path dir = Files.createDirectory(sourceDir.resolve("dir")); + // source/dir/symlink -> /tmp/file + Files.createSymbolicLink(dir.resolve("symlink"), file.toAbsolutePath()); + final Path targetDir = tempDirPath.resolve("target"); + + // When + final PathCounters pathCounters = PathUtils.copyDirectory(sourceDir, targetDir, NOFOLLOW_LINKS); + + // Then + assertEquals(0L, pathCounters.getByteCounter().get()); + assertEquals(2L, pathCounters.getDirectoryCounter().get()); + assertEquals(1L, pathCounters.getFileCounter().get()); + final Path copyOfAbsoluteSymlinkToFile = targetDir.resolve("dir").resolve("symlink"); + assertTrue(Files.isSymbolicLink(copyOfAbsoluteSymlinkToFile)); + assertTrue(Files.isRegularFile(copyOfAbsoluteSymlinkToFile)); + // Verify that /tmp/target/dir/symlink resolves to /tmp/source/file + assertEquals(file.toRealPath(), copyOfAbsoluteSymlinkToFile.toRealPath()); + } + + @Test + void testCopyDirectoryThrowsOnCyclicSymbolicLink() throws Exception { + final Path sourceDir = Files.createDirectory(tempDirPath.resolve("source")); + final Path dir1 = Files.createDirectory(sourceDir.resolve("dir1")); + final Path dir2 = Files.createDirectory(dir1.resolve("dir2")); + Files.createSymbolicLink(dir2.resolve("cyclic-symlink"), dir2.relativize(dir1)); + final Path targetDir = tempDirPath.resolve("target"); + + assertThrows(FileSystemLoopException.class, () -> PathUtils.copyDirectory(sourceDir, targetDir)); + + assertTrue(Files.exists(targetDir)); + final Path copyOfDir2 = targetDir.resolve("dir1").resolve("dir2"); + assertTrue(Files.exists(copyOfDir2)); + assertTrue(Files.isDirectory(copyOfDir2)); + assertFalse(Files.exists(copyOfDir2.resolve("cyclic-symlink"))); + } + + @Test + void testCopyDirectoryWithNoFollowLinksPreservesCyclicSymbolicLink() throws Exception { + final Path sourceDir = Files.createDirectory(tempDirPath.resolve("source")); + final Path dir1 = Files.createDirectory(sourceDir.resolve("dir1")); + final Path dir2 = Files.createDirectory(dir1.resolve("dir2")); + Files.createSymbolicLink(dir2.resolve("cyclic-symlink"), dir2.relativize(dir1)); + final Path targetDir = tempDirPath.resolve("target"); + + PathUtils.copyDirectory(sourceDir, targetDir, NOFOLLOW_LINKS); + + assertTrue(Files.exists(targetDir)); + final Path copyOfDir1 = targetDir.resolve("dir1"); + final Path copyOfDir2 = copyOfDir1.resolve("dir2"); + assertTrue(Files.exists(copyOfDir2)); + assertTrue(Files.isDirectory(copyOfDir2)); + final Path copyOfCyclicSymlink = copyOfDir2.resolve("cyclic-symlink"); + assertTrue(Files.exists(copyOfCyclicSymlink)); + assertEquals(copyOfDir1.toRealPath(), copyOfCyclicSymlink.toRealPath()); + } + + @ParameterizedTest + @ArgumentsSource(CopyOptionsArgumentsProvider.class) + void testCopyDirectoryIgnoresBrokenSymbolicLink(CopyOption... copyOptions) throws Exception { + final Path sourceDir = Files.createDirectory(tempDirPath.resolve("source")); + final Path dir = Files.createDirectory(sourceDir.resolve("dir")); + Files.createSymbolicLink(dir.resolve("broken-symlink"), dir.relativize(sourceDir.resolve("file"))); + final Path targetDir = tempDirPath.resolve("target"); + + PathUtils.copyDirectory(sourceDir, targetDir, copyOptions); + + assertTrue(Files.exists(targetDir)); + final Path copyOfDir = targetDir.resolve("dir"); + assertTrue(Files.exists(copyOfDir)); + assertTrue(Files.isDirectory(copyOfDir)); + assertFalse(Files.exists(copyOfDir.resolve("broken-symlink"))); + } + + private static class CopyOptionsArgumentsProvider implements ArgumentsProvider { + + @Override + public Stream provideArguments(ParameterDeclarations parameters, ExtensionContext context) { + return Stream.of( + Arguments.of((Object) new CopyOption[0]), + Arguments.of((Object) new CopyOption[] { NOFOLLOW_LINKS }) + ); + } + } + + @Test + void testCopyDirectoryFollowsAbsoluteSymbolicLinkToDirectory() throws Exception { + // Given + final Path externalDir = Files.createDirectory(tempDirPath.resolve("external")); + final Path dir1 = Files.createDirectory(externalDir.resolve("dir1")); + final Path file2 = Files.write(dir1.resolve("file2"), BYTE_ARRAY_FIXTURE); + final Path sourceDir = Files.createDirectory(tempDirPath.resolve("source")); + final Path dir3 = Files.createDirectory(sourceDir.resolve("dir3")); + final Path file4 = Files.write(dir3.resolve("file4"), BYTE_ARRAY_FIXTURE); + Files.createSymbolicLink(sourceDir.resolve("symlink1"), dir1.toAbsolutePath()); + Files.createSymbolicLink(sourceDir.resolve("symlink2"), sourceDir.relativize(file2)); + Files.createSymbolicLink(sourceDir.resolve("symlink3"), sourceDir.relativize(dir3)); + Files.createSymbolicLink(dir3.resolve("symlink4"), file4.toAbsolutePath()); + final Path targetDir = tempDirPath.resolve("target"); + + // When + final PathCounters pathCounters = PathUtils.copyDirectory(sourceDir, targetDir); + + // Then + // 6 * 11 bytes == 66: + // file2 + // file4 + // symlink2 -> file2 + // symlink4 -> file4 + // symlink1 -> dir1 containing file2 + // symlink3 -> dir3 containing file4 + assertEquals(66L, pathCounters.getByteCounter().get()); + assertEquals(4L, pathCounters.getDirectoryCounter().get()); + assertEquals(6L, pathCounters.getFileCounter().get()); + assertTrue(Files.exists(targetDir.resolve("dir3").resolve("file4"))); + assertTrue(Files.exists(targetDir.resolve("dir3").resolve("symlink4"))); + } }