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..0fab9c90edc 100644 --- a/src/main/java/org/apache/commons/io/file/CountingPathVisitor.java +++ b/src/main/java/org/apache/commons/io/file/CountingPathVisitor.java @@ -304,14 +304,24 @@ protected void updateDirCounter(final Path dir, final IOException exc) { } /** - * Updates the counters for visiting the given file. + * Updates the counters for visiting the given file, ignoring symbolic links. + *
+ * According to the JavaDoc, {@link 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 {@link Files#isRegularFile}. + * However, for symbolic links, {@code isRegularFile} returns {@code true} under a "follow links" regime. + * That would still not give us what we want, so instead we settle for a {@code !Files.isSymbolicLink} check. + *
* * @param file the visited file. * @param attributes the visited file attributes. */ protected void updateFileCounters(final Path file, final BasicFileAttributes attributes) { pathCounters.getFileCounter().increment(); - pathCounters.getByteCounter().add(attributes.size()); + 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..a77a1b6d82d 100644 --- a/src/main/java/org/apache/commons/io/file/PathUtils.java +++ b/src/main/java/org/apache/commons/io/file/PathUtils.java @@ -82,6 +82,7 @@ import org.apache.commons.io.file.Counters.PathCounters; import org.apache.commons.io.file.attribute.FileTimes; import org.apache.commons.io.filefilter.IOFileFilter; +import org.apache.commons.io.filefilter.TrueFileFilter; import org.apache.commons.io.function.IOFunction; import org.apache.commons.io.function.IOSupplier; @@ -227,6 +228,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+ * 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 +407,22 @@ public static long copy(final IOSupplier{@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 extends Arguments> 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")));
+ }
}