diff --git a/android/guava-tests/test/com/google/common/io/MoreFilesTest.java b/android/guava-tests/test/com/google/common/io/MoreFilesTest.java new file mode 100644 index 000000000000..a8c041bab6f8 --- /dev/null +++ b/android/guava-tests/test/com/google/common/io/MoreFilesTest.java @@ -0,0 +1,709 @@ +/* + * Copyright (C) 2013 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.common.io; + +import static com.google.common.base.StandardSystemProperty.OS_NAME; +import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE; +import static com.google.common.jimfs.Feature.SECURE_DIRECTORY_STREAM; +import static com.google.common.jimfs.Feature.SYMBOLIC_LINKS; +import static com.google.common.truth.Truth.assertThat; +import static java.nio.charset.StandardCharsets.UTF_8; +import static java.nio.file.LinkOption.NOFOLLOW_LINKS; +import static org.junit.Assert.assertThrows; + +import com.google.common.collect.ObjectArrays; +import com.google.common.jimfs.Configuration; +import com.google.common.jimfs.Feature; +import com.google.common.jimfs.Jimfs; +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemException; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.EnumSet; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import junit.framework.TestCase; + +/** + * Tests for {@link MoreFiles}. + * + *

Note: {@link MoreFiles#fileTraverser()} is tested in {@link MoreFilesFileTraverserTest}. + * + * @author Colin Decker + */ + +public class MoreFilesTest extends TestCase { + + /* + * Note: We don't include suite() in the backport. I've lost track of whether the Android test + * runner would run it even if we did, but part of the problem is b/230620681. + */ + + private static final FileSystem FS = FileSystems.getDefault(); + + private static Path root() { + return FS.getRootDirectories().iterator().next(); + } + + private Path tempDir; + + @Override + protected void setUp() throws Exception { + tempDir = Files.createTempDirectory("MoreFilesTest"); + } + + @Override + protected void tearDown() throws Exception { + if (tempDir != null) { + // delete tempDir and its contents + Files.walkFileTree( + tempDir, + new SimpleFileVisitor() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Files.deleteIfExists(file); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException exc) + throws IOException { + if (exc != null) { + return FileVisitResult.TERMINATE; + } + Files.deleteIfExists(dir); + return FileVisitResult.CONTINUE; + } + }); + } + } + + private Path createTempFile() throws IOException { + return Files.createTempFile(tempDir, "test", ".test"); + } + + public void testByteSource_size_ofDirectory() throws IOException { + try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) { + Path dir = fs.getPath("dir"); + Files.createDirectory(dir); + + ByteSource source = MoreFiles.asByteSource(dir); + + assertThat(source.sizeIfKnown()).isAbsent(); + + assertThrows(IOException.class, () -> source.size()); + } + } + + public void testByteSource_size_ofSymlinkToDirectory() throws IOException { + try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) { + Path dir = fs.getPath("dir"); + Files.createDirectory(dir); + Path link = fs.getPath("link"); + Files.createSymbolicLink(link, dir); + + ByteSource source = MoreFiles.asByteSource(link); + + assertThat(source.sizeIfKnown()).isAbsent(); + + assertThrows(IOException.class, () -> source.size()); + } + } + + public void testByteSource_size_ofSymlinkToRegularFile() throws IOException { + try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) { + Path file = fs.getPath("file"); + Files.write(file, new byte[10]); + Path link = fs.getPath("link"); + Files.createSymbolicLink(link, file); + + ByteSource source = MoreFiles.asByteSource(link); + + assertEquals(10L, (long) source.sizeIfKnown().get()); + assertEquals(10L, source.size()); + } + } + + public void testByteSource_size_ofSymlinkToRegularFile_nofollowLinks() throws IOException { + try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) { + Path file = fs.getPath("file"); + Files.write(file, new byte[10]); + Path link = fs.getPath("link"); + Files.createSymbolicLink(link, file); + + ByteSource source = MoreFiles.asByteSource(link, NOFOLLOW_LINKS); + + assertThat(source.sizeIfKnown()).isAbsent(); + + assertThrows(IOException.class, () -> source.size()); + } + } + + public void testEqual() throws IOException { + try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) { + Path fooPath = fs.getPath("foo"); + Path barPath = fs.getPath("bar"); + MoreFiles.asCharSink(fooPath, UTF_8).write("foo"); + MoreFiles.asCharSink(barPath, UTF_8).write("barbar"); + + assertThat(MoreFiles.equal(fooPath, barPath)).isFalse(); + assertThat(MoreFiles.equal(fooPath, fooPath)).isTrue(); + assertThat(MoreFiles.asByteSource(fooPath).contentEquals(MoreFiles.asByteSource(fooPath))) + .isTrue(); + + Path fooCopy = Files.copy(fooPath, fs.getPath("fooCopy")); + assertThat(Files.isSameFile(fooPath, fooCopy)).isFalse(); + assertThat(MoreFiles.equal(fooPath, fooCopy)).isTrue(); + + MoreFiles.asCharSink(fooCopy, UTF_8).write("boo"); + assertThat(MoreFiles.asByteSource(fooPath).size()) + .isEqualTo(MoreFiles.asByteSource(fooCopy).size()); + assertThat(MoreFiles.equal(fooPath, fooCopy)).isFalse(); + + // should also assert that a Path that erroneously reports a size 0 can still be compared, + // not sure how to do that with the Path API + } + } + + public void testEqual_links() throws IOException { + try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) { + Path fooPath = fs.getPath("foo"); + MoreFiles.asCharSink(fooPath, UTF_8).write("foo"); + + Path fooSymlink = fs.getPath("symlink"); + Files.createSymbolicLink(fooSymlink, fooPath); + + Path fooHardlink = fs.getPath("hardlink"); + Files.createLink(fooHardlink, fooPath); + + assertThat(MoreFiles.equal(fooPath, fooSymlink)).isTrue(); + assertThat(MoreFiles.equal(fooPath, fooHardlink)).isTrue(); + assertThat(MoreFiles.equal(fooSymlink, fooHardlink)).isTrue(); + } + } + + public void testTouch() throws IOException { + Path temp = createTempFile(); + assertTrue(Files.exists(temp)); + Files.delete(temp); + assertFalse(Files.exists(temp)); + + MoreFiles.touch(temp); + assertTrue(Files.exists(temp)); + MoreFiles.touch(temp); + assertTrue(Files.exists(temp)); + } + + public void testTouchTime() throws IOException { + Path temp = createTempFile(); + assertTrue(Files.exists(temp)); + Files.setLastModifiedTime(temp, FileTime.fromMillis(0)); + assertEquals(0, Files.getLastModifiedTime(temp).toMillis()); + MoreFiles.touch(temp); + assertThat(Files.getLastModifiedTime(temp).toMillis()).isNotEqualTo(0); + } + + public void testCreateParentDirectories_root() throws IOException { + // We use a fake filesystem to sidestep flaky problems with Windows (b/136041958). + try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) { + Path root = fs.getRootDirectories().iterator().next(); + assertNull(root.getParent()); + assertNull(root.toRealPath().getParent()); + MoreFiles.createParentDirectories(root); // test that there's no exception + } + } + + public void testCreateParentDirectories_relativePath() throws IOException { + Path path = FS.getPath("nonexistent.file"); + assertNull(path.getParent()); + assertNotNull(path.toAbsolutePath().getParent()); + MoreFiles.createParentDirectories(path); // test that there's no exception + } + + public void testCreateParentDirectories_noParentsNeeded() throws IOException { + Path path = tempDir.resolve("nonexistent.file"); + assertTrue(Files.exists(path.getParent())); + MoreFiles.createParentDirectories(path); // test that there's no exception + } + + public void testCreateParentDirectories_oneParentNeeded() throws IOException { + Path path = tempDir.resolve("parent/nonexistent.file"); + Path parent = path.getParent(); + assertFalse(Files.exists(parent)); + MoreFiles.createParentDirectories(path); + assertTrue(Files.exists(parent)); + } + + public void testCreateParentDirectories_multipleParentsNeeded() throws IOException { + Path path = tempDir.resolve("grandparent/parent/nonexistent.file"); + Path parent = path.getParent(); + Path grandparent = parent.getParent(); + assertFalse(Files.exists(grandparent)); + assertFalse(Files.exists(parent)); + + MoreFiles.createParentDirectories(path); + assertTrue(Files.exists(parent)); + assertTrue(Files.exists(grandparent)); + } + + public void testCreateParentDirectories_noPermission() { + if (isWindows()) { + return; // TODO: b/136041958 - Create/find a directory that we don't have permissions on? + } + Path file = root().resolve("parent/nonexistent.file"); + Path parent = file.getParent(); + assertFalse(Files.exists(parent)); + assertThrows(IOException.class, () -> MoreFiles.createParentDirectories(file)); + } + + public void testCreateParentDirectories_nonDirectoryParentExists() throws IOException { + Path parent = createTempFile(); + assertTrue(Files.isRegularFile(parent)); + Path file = parent.resolve("foo"); + assertThrows(IOException.class, () -> MoreFiles.createParentDirectories(file)); + } + + public void testCreateParentDirectories_symlinkParentExists() throws IOException { + /* + * We use a fake filesystem to sidestep: + * + * - flaky problems with Windows (b/136041958) + * + * - the lack of support for symlinks in the default filesystem under Android's desugared + * java.nio.file + */ + try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) { + Path symlink = fs.getPath("linkToDir"); + Files.createSymbolicLink(symlink, fs.getRootDirectories().iterator().next()); + Path file = symlink.resolve("foo"); + MoreFiles.createParentDirectories(file); + } + } + + public void testGetFileExtension() { + assertEquals("txt", MoreFiles.getFileExtension(FS.getPath(".txt"))); + assertEquals("txt", MoreFiles.getFileExtension(FS.getPath("blah.txt"))); + assertEquals("txt", MoreFiles.getFileExtension(FS.getPath("blah..txt"))); + assertEquals("txt", MoreFiles.getFileExtension(FS.getPath(".blah.txt"))); + assertEquals("txt", MoreFiles.getFileExtension(root().resolve("tmp/blah.txt"))); + assertEquals("gz", MoreFiles.getFileExtension(FS.getPath("blah.tar.gz"))); + assertEquals("", MoreFiles.getFileExtension(root())); + assertEquals("", MoreFiles.getFileExtension(FS.getPath("."))); + assertEquals("", MoreFiles.getFileExtension(FS.getPath(".."))); + assertEquals("", MoreFiles.getFileExtension(FS.getPath("..."))); + assertEquals("", MoreFiles.getFileExtension(FS.getPath("blah"))); + assertEquals("", MoreFiles.getFileExtension(FS.getPath("blah."))); + assertEquals("", MoreFiles.getFileExtension(FS.getPath(".blah."))); + assertEquals("", MoreFiles.getFileExtension(root().resolve("foo.bar/blah"))); + assertEquals("", MoreFiles.getFileExtension(root().resolve("foo/.bar/blah"))); + } + + public void testGetNameWithoutExtension() { + assertEquals("", MoreFiles.getNameWithoutExtension(FS.getPath(".txt"))); + assertEquals("blah", MoreFiles.getNameWithoutExtension(FS.getPath("blah.txt"))); + assertEquals("blah.", MoreFiles.getNameWithoutExtension(FS.getPath("blah..txt"))); + assertEquals(".blah", MoreFiles.getNameWithoutExtension(FS.getPath(".blah.txt"))); + assertEquals("blah", MoreFiles.getNameWithoutExtension(root().resolve("tmp/blah.txt"))); + assertEquals("blah.tar", MoreFiles.getNameWithoutExtension(FS.getPath("blah.tar.gz"))); + assertEquals("", MoreFiles.getNameWithoutExtension(root())); + assertEquals("", MoreFiles.getNameWithoutExtension(FS.getPath("."))); + assertEquals(".", MoreFiles.getNameWithoutExtension(FS.getPath(".."))); + assertEquals("..", MoreFiles.getNameWithoutExtension(FS.getPath("..."))); + assertEquals("blah", MoreFiles.getNameWithoutExtension(FS.getPath("blah"))); + assertEquals("blah", MoreFiles.getNameWithoutExtension(FS.getPath("blah."))); + assertEquals(".blah", MoreFiles.getNameWithoutExtension(FS.getPath(".blah."))); + assertEquals("blah", MoreFiles.getNameWithoutExtension(root().resolve("foo.bar/blah"))); + assertEquals("blah", MoreFiles.getNameWithoutExtension(root().resolve("foo/.bar/blah"))); + } + + public void testPredicates() throws IOException { + /* + * We use a fake filesystem to sidestep the lack of support for symlinks in the default + * filesystem under Android's desugared java.nio.file. + */ + try (FileSystem fs = Jimfs.newFileSystem(Configuration.unix())) { + Path file = fs.getPath("file"); + Files.createFile(file); + Path dir = fs.getPath("dir"); + Files.createDirectory(dir); + + assertTrue(MoreFiles.isDirectory().apply(dir)); + assertFalse(MoreFiles.isRegularFile().apply(dir)); + + assertFalse(MoreFiles.isDirectory().apply(file)); + assertTrue(MoreFiles.isRegularFile().apply(file)); + + Path symlinkToDir = fs.getPath("symlinkToDir"); + Path symlinkToFile = fs.getPath("symlinkToFile"); + + Files.createSymbolicLink(symlinkToDir, dir); + Files.createSymbolicLink(symlinkToFile, file); + + assertTrue(MoreFiles.isDirectory().apply(symlinkToDir)); + assertFalse(MoreFiles.isRegularFile().apply(symlinkToDir)); + + assertFalse(MoreFiles.isDirectory().apply(symlinkToFile)); + assertTrue(MoreFiles.isRegularFile().apply(symlinkToFile)); + + assertFalse(MoreFiles.isDirectory(NOFOLLOW_LINKS).apply(symlinkToDir)); + assertFalse(MoreFiles.isRegularFile(NOFOLLOW_LINKS).apply(symlinkToFile)); + } + } + + /** + * Creates a new file system for testing that supports the given features in addition to + * supporting symbolic links. The file system is created initially having the following file + * structure: + * + *

+   *   /
+   *      work/
+   *         dir/
+   *            a
+   *            b/
+   *               g
+   *               h -> ../a
+   *               i/
+   *                  j/
+   *                     k
+   *                     l/
+   *            c
+   *            d -> b/i
+   *            e/
+   *            f -> /dontdelete
+   *      dontdelete/
+   *         a
+   *         b/
+   *         c
+   *      symlinktodir -> work/dir
+   * 
+ */ + static FileSystem newTestFileSystem(Feature... supportedFeatures) throws IOException { + FileSystem fs = + Jimfs.newFileSystem( + Configuration.unix().toBuilder() + .setSupportedFeatures(ObjectArrays.concat(SYMBOLIC_LINKS, supportedFeatures)) + .build()); + Files.createDirectories(fs.getPath("dir/b/i/j/l")); + Files.createFile(fs.getPath("dir/a")); + Files.createFile(fs.getPath("dir/c")); + Files.createSymbolicLink(fs.getPath("dir/d"), fs.getPath("b/i")); + Files.createDirectory(fs.getPath("dir/e")); + Files.createSymbolicLink(fs.getPath("dir/f"), fs.getPath("/dontdelete")); + Files.createFile(fs.getPath("dir/b/g")); + Files.createSymbolicLink(fs.getPath("dir/b/h"), fs.getPath("../a")); + Files.createFile(fs.getPath("dir/b/i/j/k")); + Files.createDirectory(fs.getPath("/dontdelete")); + Files.createFile(fs.getPath("/dontdelete/a")); + Files.createDirectory(fs.getPath("/dontdelete/b")); + Files.createFile(fs.getPath("/dontdelete/c")); + Files.createSymbolicLink(fs.getPath("/symlinktodir"), fs.getPath("work/dir")); + return fs; + } + + public void testDirectoryDeletion_basic() throws IOException { + for (DirectoryDeleteMethod method : EnumSet.allOf(DirectoryDeleteMethod.class)) { + try (FileSystem fs = newTestFileSystem(SECURE_DIRECTORY_STREAM)) { + Path dir = fs.getPath("dir"); + assertEquals(6, MoreFiles.listFiles(dir).size()); + + method.delete(dir); + method.assertDeleteSucceeded(dir); + + assertEquals( + "contents of /dontdelete deleted by delete method " + method, + 3, + MoreFiles.listFiles(fs.getPath("/dontdelete")).size()); + } + } + } + + public void testDirectoryDeletion_emptyDir() throws IOException { + for (DirectoryDeleteMethod method : EnumSet.allOf(DirectoryDeleteMethod.class)) { + try (FileSystem fs = newTestFileSystem(SECURE_DIRECTORY_STREAM)) { + Path emptyDir = fs.getPath("dir/e"); + assertEquals(0, MoreFiles.listFiles(emptyDir).size()); + + method.delete(emptyDir); + method.assertDeleteSucceeded(emptyDir); + } + } + } + + public void testDeleteRecursively_symlinkToDir() throws IOException { + try (FileSystem fs = newTestFileSystem(SECURE_DIRECTORY_STREAM)) { + Path symlink = fs.getPath("/symlinktodir"); + Path dir = fs.getPath("dir"); + + assertEquals(6, MoreFiles.listFiles(dir).size()); + + MoreFiles.deleteRecursively(symlink); + + assertFalse(Files.exists(symlink)); + assertTrue(Files.exists(dir)); + assertEquals(6, MoreFiles.listFiles(dir).size()); + } + } + + public void testDeleteDirectoryContents_symlinkToDir() throws IOException { + try (FileSystem fs = newTestFileSystem(SECURE_DIRECTORY_STREAM)) { + Path symlink = fs.getPath("/symlinktodir"); + Path dir = fs.getPath("dir"); + + assertEquals(6, MoreFiles.listFiles(symlink).size()); + + MoreFiles.deleteDirectoryContents(symlink); + + assertTrue(Files.exists(symlink, NOFOLLOW_LINKS)); + assertTrue(Files.exists(symlink)); + assertTrue(Files.exists(dir)); + assertEquals(0, MoreFiles.listFiles(symlink).size()); + } + } + + public void testDirectoryDeletion_sdsNotSupported_fails() throws IOException { + for (DirectoryDeleteMethod method : EnumSet.allOf(DirectoryDeleteMethod.class)) { + try (FileSystem fs = newTestFileSystem()) { + Path dir = fs.getPath("dir"); + assertEquals(6, MoreFiles.listFiles(dir).size()); + + assertThrows(InsecureRecursiveDeleteException.class, () -> method.delete(dir)); + + assertTrue(Files.exists(dir)); + assertEquals(6, MoreFiles.listFiles(dir).size()); + } + } + } + + public void testDirectoryDeletion_sdsNotSupported_allowInsecure() throws IOException { + for (DirectoryDeleteMethod method : EnumSet.allOf(DirectoryDeleteMethod.class)) { + try (FileSystem fs = newTestFileSystem()) { + Path dir = fs.getPath("dir"); + assertEquals(6, MoreFiles.listFiles(dir).size()); + + method.delete(dir, ALLOW_INSECURE); + method.assertDeleteSucceeded(dir); + + assertEquals( + "contents of /dontdelete deleted by delete method " + method, + 3, + MoreFiles.listFiles(fs.getPath("/dontdelete")).size()); + } + } + } + + public void testDeleteRecursively_symlinkToDir_sdsNotSupported_allowInsecure() + throws IOException { + try (FileSystem fs = newTestFileSystem()) { + Path symlink = fs.getPath("/symlinktodir"); + Path dir = fs.getPath("dir"); + + assertEquals(6, MoreFiles.listFiles(dir).size()); + + MoreFiles.deleteRecursively(symlink, ALLOW_INSECURE); + + assertFalse(Files.exists(symlink)); + assertTrue(Files.exists(dir)); + assertEquals(6, MoreFiles.listFiles(dir).size()); + } + } + + public void testDeleteRecursively_nonexistingFile_throwsNoSuchFileException() throws IOException { + try (FileSystem fs = newTestFileSystem()) { + NoSuchFileException expected = + assertThrows( + NoSuchFileException.class, + () -> MoreFiles.deleteRecursively(fs.getPath("/work/nothere"), ALLOW_INSECURE)); + assertThat(expected.getFile()).isEqualTo("/work/nothere"); + } + } + + public void testDeleteDirectoryContents_symlinkToDir_sdsNotSupported_allowInsecure() + throws IOException { + try (FileSystem fs = newTestFileSystem()) { + Path symlink = fs.getPath("/symlinktodir"); + Path dir = fs.getPath("dir"); + + assertEquals(6, MoreFiles.listFiles(dir).size()); + + MoreFiles.deleteDirectoryContents(symlink, ALLOW_INSECURE); + assertEquals(0, MoreFiles.listFiles(dir).size()); + } + } + + /** + * This test attempts to create a situation in which one thread is constantly changing a file from + * being a real directory to being a symlink to another directory. It then calls + * deleteDirectoryContents thousands of times on a directory whose subtree contains the file + * that's switching between directory and symlink to try to ensure that under no circumstance does + * deleteDirectoryContents follow the symlink to the other directory and delete that directory's + * contents. + * + *

We can only test this with a file system that supports SecureDirectoryStream, because it's + * not possible to protect against this if the file system doesn't. + */ + @SuppressWarnings("ThreadPriorityCheck") // TODO: b/175898629 - Consider onSpinWait. + public void testDirectoryDeletion_directorySymlinkRace() throws IOException { + int iterations = isAndroid() ? 100 : 5000; + for (DirectoryDeleteMethod method : EnumSet.allOf(DirectoryDeleteMethod.class)) { + try (FileSystem fs = newTestFileSystem(SECURE_DIRECTORY_STREAM)) { + Path dirToDelete = fs.getPath("dir/b/i"); + Path changingFile = dirToDelete.resolve("j/l"); + Path symlinkTarget = fs.getPath("/dontdelete"); + + ExecutorService executor = Executors.newSingleThreadExecutor(); + startDirectorySymlinkSwitching(changingFile, symlinkTarget, executor); + + try { + for (int i = 0; i < iterations; i++) { + try { + Files.createDirectories(changingFile); + Files.createFile(dirToDelete.resolve("j/k")); + } catch (FileAlreadyExistsException expected) { + // if a file already exists, that's fine... just continue + } + + try { + method.delete(dirToDelete); + } catch (FileSystemException expected) { + // the delete method may or may not throw an exception, but if it does that's fine + // and expected + } + + // this test is mainly checking that the contents of /dontdelete aren't deleted under + // any circumstances + assertEquals(3, MoreFiles.listFiles(symlinkTarget).size()); + + Thread.yield(); + } + } finally { + executor.shutdownNow(); + } + } + } + } + + public void testDeleteRecursively_nonDirectoryFile() throws IOException { + try (FileSystem fs = newTestFileSystem(SECURE_DIRECTORY_STREAM)) { + Path file = fs.getPath("dir/a"); + assertTrue(Files.isRegularFile(file, NOFOLLOW_LINKS)); + + MoreFiles.deleteRecursively(file); + + assertFalse(Files.exists(file, NOFOLLOW_LINKS)); + + Path symlink = fs.getPath("/symlinktodir"); + assertTrue(Files.isSymbolicLink(symlink)); + + Path realSymlinkTarget = symlink.toRealPath(); + assertTrue(Files.isDirectory(realSymlinkTarget, NOFOLLOW_LINKS)); + + MoreFiles.deleteRecursively(symlink); + + assertFalse(Files.exists(symlink, NOFOLLOW_LINKS)); + assertTrue(Files.isDirectory(realSymlinkTarget, NOFOLLOW_LINKS)); + } + } + + /** + * Starts a new task on the given executor that switches (deletes and replaces) a file between + * being a directory and being a symlink. The given {@code file} is the file that should switch + * between being a directory and being a symlink, while the given {@code target} is the target the + * symlink should have. + */ + @SuppressWarnings("ThreadPriorityCheck") // TODO: b/175898629 - Consider onSpinWait. + private static void startDirectorySymlinkSwitching( + final Path file, final Path target, ExecutorService executor) { + @SuppressWarnings("unused") // https://errorprone.info/bugpattern/FutureReturnValueIgnored + Future possiblyIgnoredError = + executor.submit( + new Runnable() { + @Override + public void run() { + boolean createSymlink = false; + while (!Thread.interrupted()) { + try { + // trying to switch between a real directory and a symlink (dir -> /a) + if (Files.deleteIfExists(file)) { + if (createSymlink) { + Files.createSymbolicLink(file, target); + } else { + Files.createDirectory(file); + } + createSymlink = !createSymlink; + } + } catch (IOException tolerated) { + // it's expected that some of these will fail + } + + Thread.yield(); + } + } + }); + } + + /** Enum defining the two MoreFiles methods that delete directory contents. */ + private enum DirectoryDeleteMethod { + DELETE_DIRECTORY_CONTENTS { + @Override + public void delete(Path path, RecursiveDeleteOption... options) throws IOException { + MoreFiles.deleteDirectoryContents(path, options); + } + + @Override + public void assertDeleteSucceeded(Path path) throws IOException { + assertEquals( + "contents of directory " + path + " not deleted with delete method " + this, + 0, + MoreFiles.listFiles(path).size()); + } + }, + DELETE_RECURSIVELY { + @Override + public void delete(Path path, RecursiveDeleteOption... options) throws IOException { + MoreFiles.deleteRecursively(path, options); + } + + @Override + public void assertDeleteSucceeded(Path path) throws IOException { + assertFalse("file " + path + " not deleted with delete method " + this, Files.exists(path)); + } + }; + + public abstract void delete(Path path, RecursiveDeleteOption... options) throws IOException; + + public abstract void assertDeleteSucceeded(Path path) throws IOException; + } + + private static boolean isWindows() { + return OS_NAME.value().startsWith("Windows"); + } + + private static boolean isAndroid() { + return System.getProperty("java.runtime.name", "").contains("Android"); + } +} diff --git a/android/guava/src/com/google/common/io/InsecureRecursiveDeleteException.java b/android/guava/src/com/google/common/io/InsecureRecursiveDeleteException.java new file mode 100644 index 000000000000..83c7e72e831b --- /dev/null +++ b/android/guava/src/com/google/common/io/InsecureRecursiveDeleteException.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.common.io; + +import com.google.common.annotations.GwtIncompatible; +import com.google.common.annotations.J2ktIncompatible; +import com.google.j2objc.annotations.J2ObjCIncompatible; +import java.nio.file.FileSystemException; +import java.nio.file.SecureDirectoryStream; +import javax.annotation.CheckForNull; + +/** + * Exception indicating that a recursive delete can't be performed because the file system does not + * have the support necessary to guarantee that it is not vulnerable to race conditions that would + * allow it to delete files and directories outside of the directory being deleted (i.e., {@link + * SecureDirectoryStream} is not supported). + * + *

{@link RecursiveDeleteOption#ALLOW_INSECURE} can be used to force the recursive delete method + * to proceed anyway. + * + * @since NEXT (but since 21.0 in the JRE flavor) + * @author Colin Decker + */ +@J2ktIncompatible +@GwtIncompatible +@J2ObjCIncompatible // java.nio.file +@ElementTypesAreNonnullByDefault +// Users are unlikely to use this unless they're already interacting with MoreFiles and Path. +@IgnoreJRERequirement +public final class InsecureRecursiveDeleteException extends FileSystemException { + + public InsecureRecursiveDeleteException(@CheckForNull String file) { + super(file, null, "unable to guarantee security of recursive delete"); + } +} diff --git a/android/guava/src/com/google/common/io/MoreFiles.java b/android/guava/src/com/google/common/io/MoreFiles.java new file mode 100644 index 000000000000..a2bd644e4478 --- /dev/null +++ b/android/guava/src/com/google/common/io/MoreFiles.java @@ -0,0 +1,874 @@ +/* + * Copyright (C) 2013 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.common.io; + +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.Iterables.getOnlyElement; +import static java.nio.file.LinkOption.NOFOLLOW_LINKS; +import static java.util.Objects.requireNonNull; + +import com.google.common.annotations.GwtIncompatible; +import com.google.common.annotations.J2ktIncompatible; +import com.google.common.base.Optional; +import com.google.common.base.Predicate; +import com.google.common.collect.ImmutableList; +import com.google.common.graph.Traverser; +import com.google.j2objc.annotations.J2ObjCIncompatible; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.channels.Channels; +import java.nio.channels.SeekableByteChannel; +import java.nio.charset.Charset; +import java.nio.file.DirectoryIteratorException; +import java.nio.file.DirectoryStream; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.NoSuchFileException; +import java.nio.file.NotDirectoryException; +import java.nio.file.OpenOption; +import java.nio.file.Path; +import java.nio.file.SecureDirectoryStream; +import java.nio.file.StandardOpenOption; +import java.nio.file.attribute.BasicFileAttributeView; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileAttribute; +import java.nio.file.attribute.FileTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.stream.Stream; +import javax.annotation.CheckForNull; + +/** + * Static utilities for use with {@link Path} instances, intended to complement {@link Files}. + * + *

Many methods provided by Guava's {@code Files} class for {@link java.io.File} instances are + * now available via the JDK's {@link java.nio.file.Files} class for {@code Path} - check the JDK's + * class if a sibling method from {@code Files} appears to be missing from this class. + * + * @since NEXT (but since 21.0 in the JRE flavor) + * @author Colin Decker + */ +@J2ktIncompatible +@GwtIncompatible +@J2ObjCIncompatible // java.nio.file +@ElementTypesAreNonnullByDefault +@IgnoreJRERequirement // Users will use this only if they're already using Path. +public final class MoreFiles { + + private MoreFiles() {} + + /** + * Returns a view of the given {@code path} as a {@link ByteSource}. + * + *

Any {@linkplain OpenOption open options} provided are used when opening streams to the file + * and may affect the behavior of the returned source and the streams it provides. See {@link + * StandardOpenOption} for the standard options that may be provided. Providing no options is + * equivalent to providing the {@link StandardOpenOption#READ READ} option. + */ + public static ByteSource asByteSource(Path path, OpenOption... options) { + return new PathByteSource(path, options); + } + + @IgnoreJRERequirement // *should* be redundant with the one on MoreFiles itself + private static final class PathByteSource extends + ByteSource + { + + private static final LinkOption[] FOLLOW_LINKS = {}; + + private final Path path; + private final OpenOption[] options; + private final boolean followLinks; + + private PathByteSource(Path path, OpenOption... options) { + this.path = checkNotNull(path); + this.options = options.clone(); + this.followLinks = followLinks(this.options); + // TODO(cgdecker): validate the provided options... for example, just WRITE seems wrong + } + + private static boolean followLinks(OpenOption[] options) { + for (OpenOption option : options) { + if (option == NOFOLLOW_LINKS) { + return false; + } + } + return true; + } + + @Override + public InputStream openStream() throws IOException { + return Files.newInputStream(path, options); + } + + private BasicFileAttributes readAttributes() throws IOException { + return Files.readAttributes( + path, + BasicFileAttributes.class, + followLinks ? FOLLOW_LINKS : new LinkOption[] {NOFOLLOW_LINKS}); + } + + @Override + public Optional sizeIfKnown() { + BasicFileAttributes attrs; + try { + attrs = readAttributes(); + } catch (IOException e) { + // Failed to get attributes; we don't know the size. + return Optional.absent(); + } + + // Don't return a size for directories or symbolic links; their sizes are implementation + // specific and they can't be read as bytes using the read methods anyway. + if (attrs.isDirectory() || attrs.isSymbolicLink()) { + return Optional.absent(); + } + + return Optional.of(attrs.size()); + } + + @Override + public long size() throws IOException { + BasicFileAttributes attrs = readAttributes(); + + // Don't return a size for directories or symbolic links; their sizes are implementation + // specific and they can't be read as bytes using the read methods anyway. + if (attrs.isDirectory()) { + throw new IOException("can't read: is a directory"); + } else if (attrs.isSymbolicLink()) { + throw new IOException("can't read: is a symbolic link"); + } + + return attrs.size(); + } + + @Override + public byte[] read() throws IOException { + try (SeekableByteChannel channel = Files.newByteChannel(path, options)) { + return ByteStreams.toByteArray(Channels.newInputStream(channel), channel.size()); + } + } + + @Override + public CharSource asCharSource(Charset charset) { + if (options.length == 0) { + // If no OpenOptions were passed, delegate to Files.lines, which could have performance + // advantages. (If OpenOptions were passed we can't, because Files.lines doesn't have an + // overload taking OpenOptions, meaning we can't guarantee the same behavior w.r.t. things + // like following/not following symlinks.) + return new AsCharSource(charset) { + @SuppressWarnings({ + "FilesLinesLeak", // the user needs to close it in this case + /* + * If users use this when they shouldn't, we hope that NewApi will catch subsequent + * Stream calls. + * + * Anyway, this is just an override that is no more dangerous than the supermethod. + */ + "Java7ApiChecker", + }) + @Override + public Stream lines() throws IOException { + return Files.lines(path, charset); + } + }; + } + + return super.asCharSource(charset); + } + + @Override + public String toString() { + return "MoreFiles.asByteSource(" + path + ", " + Arrays.toString(options) + ")"; + } + } + + /** + * Returns a view of the given {@code path} as a {@link ByteSink}. + * + *

Any {@linkplain OpenOption open options} provided are used when opening streams to the file + * and may affect the behavior of the returned sink and the streams it provides. See {@link + * StandardOpenOption} for the standard options that may be provided. Providing no options is + * equivalent to providing the {@link StandardOpenOption#CREATE CREATE}, {@link + * StandardOpenOption#TRUNCATE_EXISTING TRUNCATE_EXISTING} and {@link StandardOpenOption#WRITE + * WRITE} options. + */ + public static ByteSink asByteSink(Path path, OpenOption... options) { + return new PathByteSink(path, options); + } + + @IgnoreJRERequirement // *should* be redundant with the one on MoreFiles itself + private static final class PathByteSink extends ByteSink { + + private final Path path; + private final OpenOption[] options; + + private PathByteSink(Path path, OpenOption... options) { + this.path = checkNotNull(path); + this.options = options.clone(); + // TODO(cgdecker): validate the provided options... for example, just READ seems wrong + } + + @Override + public OutputStream openStream() throws IOException { + return Files.newOutputStream(path, options); + } + + @Override + public String toString() { + return "MoreFiles.asByteSink(" + path + ", " + Arrays.toString(options) + ")"; + } + } + + /** + * Returns a view of the given {@code path} as a {@link CharSource} using the given {@code + * charset}. + * + *

Any {@linkplain OpenOption open options} provided are used when opening streams to the file + * and may affect the behavior of the returned source and the streams it provides. See {@link + * StandardOpenOption} for the standard options that may be provided. Providing no options is + * equivalent to providing the {@link StandardOpenOption#READ READ} option. + */ + public static CharSource asCharSource(Path path, Charset charset, OpenOption... options) { + return asByteSource(path, options).asCharSource(charset); + } + + /** + * Returns a view of the given {@code path} as a {@link CharSink} using the given {@code charset}. + * + *

Any {@linkplain OpenOption open options} provided are used when opening streams to the file + * and may affect the behavior of the returned sink and the streams it provides. See {@link + * StandardOpenOption} for the standard options that may be provided. Providing no options is + * equivalent to providing the {@link StandardOpenOption#CREATE CREATE}, {@link + * StandardOpenOption#TRUNCATE_EXISTING TRUNCATE_EXISTING} and {@link StandardOpenOption#WRITE + * WRITE} options. + */ + public static CharSink asCharSink(Path path, Charset charset, OpenOption... options) { + return asByteSink(path, options).asCharSink(charset); + } + + /** + * Returns an immutable list of paths to the files contained in the given directory. + * + * @throws NoSuchFileException if the file does not exist (optional specific exception) + * @throws NotDirectoryException if the file could not be opened because it is not a directory + * (optional specific exception) + * @throws IOException if an I/O error occurs + */ + public static ImmutableList listFiles(Path dir) throws IOException { + try (DirectoryStream stream = Files.newDirectoryStream(dir)) { + return ImmutableList.copyOf(stream); + } catch (DirectoryIteratorException e) { + throw e.getCause(); + } + } + + /** + * Returns a {@link Traverser} instance for the file and directory tree. The returned traverser + * starts from a {@link Path} and will return all files and directories it encounters. + * + *

The returned traverser attempts to avoid following symbolic links to directories. However, + * the traverser cannot guarantee that it will not follow symbolic links to directories as it is + * possible for a directory to be replaced with a symbolic link between checking if the file is a + * directory and actually reading the contents of that directory. + * + *

If the {@link Path} passed to one of the traversal methods does not exist or is not a + * directory, no exception will be thrown and the returned {@link Iterable} will contain a single + * element: that path. + * + *

{@link DirectoryIteratorException} may be thrown when iterating {@link Iterable} instances + * created by this traverser if an {@link IOException} is thrown by a call to {@link + * #listFiles(Path)}. + * + *

Example: {@code MoreFiles.fileTraverser().depthFirstPreOrder(Paths.get("/"))} may return the + * following paths: {@code ["/", "/etc", "/etc/config.txt", "/etc/fonts", "/home", "/home/alice", + * ...]} + * + * @since 23.5 + */ + public static Traverser fileTraverser() { + return Traverser.forTree(MoreFiles::fileTreeChildren); + } + + private static Iterable fileTreeChildren(Path dir) { + if (Files.isDirectory(dir, NOFOLLOW_LINKS)) { + try { + return listFiles(dir); + } catch (IOException e) { + // the exception thrown when iterating a DirectoryStream if an I/O exception occurs + throw new DirectoryIteratorException(e); + } + } + return ImmutableList.of(); + } + + /** + * Returns a predicate that returns the result of {@link java.nio.file.Files#isDirectory(Path, + * LinkOption...)} on input paths with the given link options. + */ + public static Predicate isDirectory(LinkOption... options) { + final LinkOption[] optionsCopy = options.clone(); + return new Predicate() { + @Override + public boolean apply(Path input) { + return Files.isDirectory(input, optionsCopy); + } + + @Override + public String toString() { + return "MoreFiles.isDirectory(" + Arrays.toString(optionsCopy) + ")"; + } + }; + } + + /** Returns whether or not the file with the given name in the given dir is a directory. */ + private static boolean isDirectory( + SecureDirectoryStream dir, Path name, LinkOption... options) throws IOException { + return dir.getFileAttributeView(name, BasicFileAttributeView.class, options) + .readAttributes() + .isDirectory(); + } + + /** + * Returns a predicate that returns the result of {@link java.nio.file.Files#isRegularFile(Path, + * LinkOption...)} on input paths with the given link options. + */ + public static Predicate isRegularFile(LinkOption... options) { + final LinkOption[] optionsCopy = options.clone(); + return new Predicate() { + @Override + public boolean apply(Path input) { + return Files.isRegularFile(input, optionsCopy); + } + + @Override + public String toString() { + return "MoreFiles.isRegularFile(" + Arrays.toString(optionsCopy) + ")"; + } + }; + } + + /** + * Returns true if the files located by the given paths exist, are not directories, and contain + * the same bytes. + * + * @throws IOException if an I/O error occurs + * @since 22.0 + */ + public static boolean equal(Path path1, Path path2) throws IOException { + checkNotNull(path1); + checkNotNull(path2); + if (Files.isSameFile(path1, path2)) { + return true; + } + + /* + * Some operating systems may return zero as the length for files denoting system-dependent + * entities such as devices or pipes, in which case we must fall back on comparing the bytes + * directly. + */ + ByteSource source1 = asByteSource(path1); + ByteSource source2 = asByteSource(path2); + long len1 = source1.sizeIfKnown().or(0L); + long len2 = source2.sizeIfKnown().or(0L); + if (len1 != 0 && len2 != 0 && len1 != len2) { + return false; + } + return source1.contentEquals(source2); + } + + /** + * Like the unix command of the same name, creates an empty file or updates the last modified + * timestamp of the existing file at the given path to the current system time. + */ + @SuppressWarnings("GoodTime") // reading system time without TimeSource + public static void touch(Path path) throws IOException { + checkNotNull(path); + + try { + Files.setLastModifiedTime(path, FileTime.fromMillis(System.currentTimeMillis())); + } catch (NoSuchFileException e) { + try { + Files.createFile(path); + } catch (FileAlreadyExistsException ignore) { + // The file didn't exist when we called setLastModifiedTime, but it did when we called + // createFile, so something else created the file in between. The end result is + // what we wanted: a new file that probably has its last modified time set to approximately + // now. Or it could have an arbitrary last modified time set by the creator, but that's no + // different than if another process set its last modified time to something else after we + // created it here. + } + } + } + + /** + * Creates any necessary but nonexistent parent directories of the specified path. Note that if + * this operation fails, it may have succeeded in creating some (but not all) of the necessary + * parent directories. The parent directory is created with the given {@code attrs}. + * + * @throws IOException if an I/O error occurs, or if any necessary but nonexistent parent + * directories of the specified file could not be created. + */ + public static void createParentDirectories(Path path, FileAttribute... attrs) + throws IOException { + // Interestingly, unlike File.getCanonicalFile(), Path/Files provides no way of getting the + // canonical (absolute, normalized, symlinks resolved, etc.) form of a path to a nonexistent + // file. getCanonicalFile() can at least get the canonical form of the part of the path which + // actually exists and then append the normalized remainder of the path to that. + Path normalizedAbsolutePath = path.toAbsolutePath().normalize(); + Path parent = normalizedAbsolutePath.getParent(); + if (parent == null) { + // The given directory is a filesystem root. All zero of its ancestors exist. This doesn't + // mean that the root itself exists -- consider x:\ on a Windows machine without such a + // drive -- or even that the caller can create it, but this method makes no such guarantees + // even for non-root files. + return; + } + + // Check if the parent is a directory first because createDirectories will fail if the parent + // exists and is a symlink to a directory... we'd like for this to succeed in that case. + // (I'm kind of surprised that createDirectories would fail in that case; doesn't seem like + // what you'd want to happen.) + if (!Files.isDirectory(parent)) { + Files.createDirectories(parent, attrs); + if (!Files.isDirectory(parent)) { + throw new IOException("Unable to create parent directories of " + path); + } + } + } + + /** + * Returns the file extension for + * the file at the given path, or the empty string if the file has no extension. The result does + * not include the '{@code .}'. + * + *

Note: This method simply returns everything after the last '{@code .}' in the file's + * name as determined by {@link Path#getFileName}. It does not account for any filesystem-specific + * behavior that the {@link Path} API does not already account for. For example, on NTFS it will + * report {@code "txt"} as the extension for the filename {@code "foo.exe:.txt"} even though NTFS + * will drop the {@code ":.txt"} part of the name when the file is actually created on the + * filesystem due to NTFS's Alternate + * Data Streams. + */ + public static String getFileExtension(Path path) { + Path name = path.getFileName(); + + // null for empty paths and root-only paths + if (name == null) { + return ""; + } + + String fileName = name.toString(); + int dotIndex = fileName.lastIndexOf('.'); + return dotIndex == -1 ? "" : fileName.substring(dotIndex + 1); + } + + /** + * Returns the file name without its file extension or path. This is + * similar to the {@code basename} unix command. The result does not include the '{@code .}'. + */ + public static String getNameWithoutExtension(Path path) { + Path name = path.getFileName(); + + // null for empty paths and root-only paths + if (name == null) { + return ""; + } + + String fileName = name.toString(); + int dotIndex = fileName.lastIndexOf('.'); + return dotIndex == -1 ? fileName : fileName.substring(0, dotIndex); + } + + /** + * Deletes the file or directory at the given {@code path} recursively. Deletes symbolic links, + * not their targets (subject to the caveat below). + * + *

If an I/O exception occurs attempting to read, open or delete any file under the given + * directory, this method skips that file and continues. All such exceptions are collected and, + * after attempting to delete all files, an {@code IOException} is thrown containing those + * exceptions as {@linkplain Throwable#getSuppressed() suppressed exceptions}. + * + *

Warning: Security of recursive deletes

+ * + *

On a file system that supports symbolic links and does not support {@link + * SecureDirectoryStream}, it is possible for a recursive delete to delete files and directories + * that are outside the directory being deleted. This can happen if, after checking that a + * file is a directory (and not a symbolic link), that directory is replaced by a symbolic link to + * an outside directory before the call that opens the directory to read its entries. + * + *

By default, this method throws {@link InsecureRecursiveDeleteException} if it can't + * guarantee the security of recursive deletes. If you wish to allow the recursive deletes anyway, + * pass {@link RecursiveDeleteOption#ALLOW_INSECURE} to this method to override that behavior. + * + * @throws NoSuchFileException if {@code path} does not exist (optional specific exception) + * @throws InsecureRecursiveDeleteException if the security of recursive deletes can't be + * guaranteed for the file system and {@link RecursiveDeleteOption#ALLOW_INSECURE} was not + * specified + * @throws IOException if {@code path} or any file in the subtree rooted at it can't be deleted + * for any reason + */ + public static void deleteRecursively(Path path, RecursiveDeleteOption... options) + throws IOException { + Path parentPath = getParentPath(path); + if (parentPath == null) { + throw new FileSystemException(path.toString(), null, "can't delete recursively"); + } + + Collection exceptions = null; // created lazily if needed + try { + boolean sdsSupported = false; + try (DirectoryStream parent = Files.newDirectoryStream(parentPath)) { + if (parent instanceof SecureDirectoryStream) { + sdsSupported = true; + exceptions = + deleteRecursivelySecure( + (SecureDirectoryStream) parent, + /* + * requireNonNull is safe because paths have file names when they have parents, + * and we checked for a parent at the beginning of the method. + */ + requireNonNull(path.getFileName())); + } + } + + if (!sdsSupported) { + checkAllowsInsecure(path, options); + exceptions = deleteRecursivelyInsecure(path); + } + } catch (IOException e) { + if (exceptions == null) { + throw e; + } else { + exceptions.add(e); + } + } + + if (exceptions != null) { + throwDeleteFailed(path, exceptions); + } + } + + /** + * Deletes all files within the directory at the given {@code path} {@linkplain #deleteRecursively + * recursively}. Does not delete the directory itself. Deletes symbolic links, not their targets + * (subject to the caveat below). If {@code path} itself is a symbolic link to a directory, that + * link is followed and the contents of the directory it targets are deleted. + * + *

If an I/O exception occurs attempting to read, open or delete any file under the given + * directory, this method skips that file and continues. All such exceptions are collected and, + * after attempting to delete all files, an {@code IOException} is thrown containing those + * exceptions as {@linkplain Throwable#getSuppressed() suppressed exceptions}. + * + *

Warning: Security of recursive deletes

+ * + *

On a file system that supports symbolic links and does not support {@link + * SecureDirectoryStream}, it is possible for a recursive delete to delete files and directories + * that are outside the directory being deleted. This can happen if, after checking that a + * file is a directory (and not a symbolic link), that directory is replaced by a symbolic link to + * an outside directory before the call that opens the directory to read its entries. + * + *

By default, this method throws {@link InsecureRecursiveDeleteException} if it can't + * guarantee the security of recursive deletes. If you wish to allow the recursive deletes anyway, + * pass {@link RecursiveDeleteOption#ALLOW_INSECURE} to this method to override that behavior. + * + * @throws NoSuchFileException if {@code path} does not exist (optional specific exception) + * @throws NotDirectoryException if the file at {@code path} is not a directory (optional + * specific exception) + * @throws InsecureRecursiveDeleteException if the security of recursive deletes can't be + * guaranteed for the file system and {@link RecursiveDeleteOption#ALLOW_INSECURE} was not + * specified + * @throws IOException if one or more files can't be deleted for any reason + */ + public static void deleteDirectoryContents(Path path, RecursiveDeleteOption... options) + throws IOException { + Collection exceptions = null; // created lazily if needed + try (DirectoryStream stream = Files.newDirectoryStream(path)) { + if (stream instanceof SecureDirectoryStream) { + SecureDirectoryStream sds = (SecureDirectoryStream) stream; + exceptions = deleteDirectoryContentsSecure(sds); + } else { + checkAllowsInsecure(path, options); + exceptions = deleteDirectoryContentsInsecure(stream); + } + } catch (IOException e) { + if (exceptions == null) { + throw e; + } else { + exceptions.add(e); + } + } + + if (exceptions != null) { + throwDeleteFailed(path, exceptions); + } + } + + /** + * Secure recursive delete using {@code SecureDirectoryStream}. Returns a collection of exceptions + * that occurred or null if no exceptions were thrown. + */ + @CheckForNull + private static Collection deleteRecursivelySecure( + SecureDirectoryStream dir, Path path) { + Collection exceptions = null; + try { + if (isDirectory(dir, path, NOFOLLOW_LINKS)) { + try (SecureDirectoryStream childDir = dir.newDirectoryStream(path, NOFOLLOW_LINKS)) { + exceptions = deleteDirectoryContentsSecure(childDir); + } + + // If exceptions is not null, something went wrong trying to delete the contents of the + // directory, so we shouldn't try to delete the directory as it will probably fail. + if (exceptions == null) { + dir.deleteDirectory(path); + } + } else { + dir.deleteFile(path); + } + + return exceptions; + } catch (IOException e) { + return addException(exceptions, e); + } + } + + /** + * Secure method for deleting the contents of a directory using {@code SecureDirectoryStream}. + * Returns a collection of exceptions that occurred or null if no exceptions were thrown. + */ + @CheckForNull + private static Collection deleteDirectoryContentsSecure( + SecureDirectoryStream dir) { + Collection exceptions = null; + try { + for (Path path : dir) { + exceptions = concat(exceptions, deleteRecursivelySecure(dir, path.getFileName())); + } + + return exceptions; + } catch (DirectoryIteratorException e) { + return addException(exceptions, e.getCause()); + } + } + + /** + * Insecure recursive delete for file systems that don't support {@code SecureDirectoryStream}. + * Returns a collection of exceptions that occurred or null if no exceptions were thrown. + */ + @CheckForNull + private static Collection deleteRecursivelyInsecure(Path path) { + Collection exceptions = null; + try { + if (Files.isDirectory(path, NOFOLLOW_LINKS)) { + try (DirectoryStream stream = Files.newDirectoryStream(path)) { + exceptions = deleteDirectoryContentsInsecure(stream); + } + } + + // If exceptions is not null, something went wrong trying to delete the contents of the + // directory, so we shouldn't try to delete the directory as it will probably fail. + if (exceptions == null) { + Files.delete(path); + } + + return exceptions; + } catch (IOException e) { + return addException(exceptions, e); + } + } + + /** + * Simple, insecure method for deleting the contents of a directory for file systems that don't + * support {@code SecureDirectoryStream}. Returns a collection of exceptions that occurred or null + * if no exceptions were thrown. + */ + @CheckForNull + private static Collection deleteDirectoryContentsInsecure( + DirectoryStream dir) { + Collection exceptions = null; + try { + for (Path entry : dir) { + exceptions = concat(exceptions, deleteRecursivelyInsecure(entry)); + } + + return exceptions; + } catch (DirectoryIteratorException e) { + return addException(exceptions, e.getCause()); + } + } + + /** + * Returns a path to the parent directory of the given path. If the path actually has a parent + * path, this is simple. Otherwise, we need to do some trickier things. Returns null if the path + * is a root or is the empty path. + */ + @CheckForNull + private static Path getParentPath(Path path) { + Path parent = path.getParent(); + + // Paths that have a parent: + if (parent != null) { + // "/foo" ("/") + // "foo/bar" ("foo") + // "C:\foo" ("C:\") + // "\foo" ("\" - current drive for process on Windows) + // "C:foo" ("C:" - working dir of drive C on Windows) + return parent; + } + + // Paths that don't have a parent: + if (path.getNameCount() == 0) { + // "/", "C:\", "\" (no parent) + // "" (undefined, though typically parent of working dir) + // "C:" (parent of working dir of drive C on Windows) + // + // For working dir paths ("" and "C:"), return null because: + // A) it's not specified that "" is the path to the working directory. + // B) if we're getting this path for recursive delete, it's typically not possible to + // delete the working dir with a relative path anyway, so it's ok to fail. + // C) if we're getting it for opening a new SecureDirectoryStream, there's no need to get + // the parent path anyway since we can safely open a DirectoryStream to the path without + // worrying about a symlink. + return null; + } else { + // "foo" (working dir) + return path.getFileSystem().getPath("."); + } + } + + /** Checks that the given options allow an insecure delete, throwing an exception if not. */ + private static void checkAllowsInsecure(Path path, RecursiveDeleteOption[] options) + throws InsecureRecursiveDeleteException { + if (!Arrays.asList(options).contains(RecursiveDeleteOption.ALLOW_INSECURE)) { + throw new InsecureRecursiveDeleteException(path.toString()); + } + } + + /** + * Adds the given exception to the given collection, creating the collection if it's null. Returns + * the collection. + */ + private static Collection addException( + @CheckForNull Collection exceptions, IOException e) { + if (exceptions == null) { + exceptions = new ArrayList<>(); // don't need Set semantics + } + exceptions.add(e); + return exceptions; + } + + /** + * Concatenates the contents of the two given collections of exceptions. If either collection is + * null, the other collection is returned. Otherwise, the elements of {@code other} are added to + * {@code exceptions} and {@code exceptions} is returned. + */ + @CheckForNull + private static Collection concat( + @CheckForNull Collection exceptions, + @CheckForNull Collection other) { + if (exceptions == null) { + return other; + } else if (other != null) { + exceptions.addAll(other); + } + return exceptions; + } + + /** + * Throws an exception indicating that one or more files couldn't be deleted when deleting {@code + * path} or its contents. + * + *

If there is only one exception in the collection, and it is a {@link NoSuchFileException} + * thrown because {@code path} itself didn't exist, then throws that exception. Otherwise, the + * thrown exception contains all the exceptions in the given collection as suppressed exceptions. + */ + private static void throwDeleteFailed(Path path, Collection exceptions) + throws FileSystemException { + NoSuchFileException pathNotFound = pathNotFound(path, exceptions); + if (pathNotFound != null) { + throw pathNotFound; + } + // TODO(cgdecker): Should there be a custom exception type for this? + // Also, should we try to include the Path of each file we may have failed to delete rather + // than just the exceptions that occurred? + FileSystemException deleteFailed = + new FileSystemException( + path.toString(), + null, + "failed to delete one or more files; see suppressed exceptions for details"); + for (IOException e : exceptions) { + deleteFailed.addSuppressed(e); + } + throw deleteFailed; + } + + @CheckForNull + private static NoSuchFileException pathNotFound(Path path, Collection exceptions) { + if (exceptions.size() != 1) { + return null; + } + IOException exception = getOnlyElement(exceptions); + if (!(exception instanceof NoSuchFileException)) { + return null; + } + NoSuchFileException noSuchFileException = (NoSuchFileException) exception; + String exceptionFile = noSuchFileException.getFile(); + if (exceptionFile == null) { + /* + * It's not clear whether this happens in practice, especially with the filesystem + * implementations that are built into java.nio. + */ + return null; + } + Path parentPath = getParentPath(path); + if (parentPath == null) { + /* + * This is probably impossible: + * + * - In deleteRecursively, we require the path argument to have a parent. + * + * - In deleteDirectoryContents, the path argument may have no parent. Fortunately, all the + * *other* paths we process will be descendants of that. That leaves only the original path + * argument for us to consider. And the only place we call pathNotFound is from + * throwDeleteFailed, and the other place that we call throwDeleteFailed inside + * deleteDirectoryContents is when an exception is thrown during the recursive steps. Any + * failure during the initial lookup of the path argument itself is rethrown directly. So + * any exception that we're seeing here is from a descendant, which naturally has a parent. + * I think. + * + * Still, if this can happen somehow (a weird filesystem implementation that lets callers + * change its working directly concurrently with a call to deleteDirectoryContents?), it makes + * more sense for us to fall back to a generic FileSystemException (by returning null here) + * than to dereference parentPath and end up producing NullPointerException. + */ + return null; + } + // requireNonNull is safe because paths have file names when they have parents. + Path pathResolvedFromParent = parentPath.resolve(requireNonNull(path.getFileName())); + if (exceptionFile.equals(pathResolvedFromParent.toString())) { + return noSuchFileException; + } + return null; + } +} diff --git a/android/guava/src/com/google/common/io/RecursiveDeleteOption.java b/android/guava/src/com/google/common/io/RecursiveDeleteOption.java new file mode 100644 index 000000000000..4b8e78d374c9 --- /dev/null +++ b/android/guava/src/com/google/common/io/RecursiveDeleteOption.java @@ -0,0 +1,49 @@ +/* + * Copyright (C) 2014 The Guava Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.common.io; + +import com.google.common.annotations.GwtIncompatible; +import com.google.common.annotations.J2ktIncompatible; +import com.google.j2objc.annotations.J2ObjCIncompatible; +import java.nio.file.SecureDirectoryStream; + +/** + * Options for use with recursive delete methods ({@link MoreFiles#deleteRecursively} and {@link + * MoreFiles#deleteDirectoryContents}). + * + * @since NEXT (but since 21.0 in the JRE flavor) + * @author Colin Decker + */ +@J2ktIncompatible +@GwtIncompatible +@J2ObjCIncompatible // java.nio.file +@ElementTypesAreNonnullByDefault +public enum RecursiveDeleteOption { + /** + * Specifies that the recursive delete should not throw an exception when it can't be guaranteed + * that it can be done securely, without vulnerability to race conditions (i.e. when the file + * system does not support {@link SecureDirectoryStream}). + * + *

Warning: On a file system that supports symbolic links, it is possible for an + * insecure recursive delete to delete files and directories that are outside the directory + * being deleted. This can happen if, after checking that a file is a directory (and not a + * symbolic link), that directory is deleted and replaced by a symbolic link to an outside + * directory before the call that opens the directory to read its entries. File systems that + * support {@code SecureDirectoryStream} do not have this vulnerability. + */ + ALLOW_INSECURE +} diff --git a/guava/src/com/google/common/io/InsecureRecursiveDeleteException.java b/guava/src/com/google/common/io/InsecureRecursiveDeleteException.java index c292879ba370..601ee9a8d739 100644 --- a/guava/src/com/google/common/io/InsecureRecursiveDeleteException.java +++ b/guava/src/com/google/common/io/InsecureRecursiveDeleteException.java @@ -32,7 +32,7 @@ *

{@link RecursiveDeleteOption#ALLOW_INSECURE} can be used to force the recursive delete method * to proceed anyway. * - * @since 21.0 + * @since 21.0 (but only since 33.4.0 in the Android flavor) * @author Colin Decker */ @J2ktIncompatible diff --git a/guava/src/com/google/common/io/MoreFiles.java b/guava/src/com/google/common/io/MoreFiles.java index f9626022391d..4c09f58e6603 100644 --- a/guava/src/com/google/common/io/MoreFiles.java +++ b/guava/src/com/google/common/io/MoreFiles.java @@ -63,7 +63,7 @@ * now available via the JDK's {@link java.nio.file.Files} class for {@code Path} - check the JDK's * class if a sibling method from {@code Files} appears to be missing from this class. * - * @since 21.0 + * @since 21.0 (but only since 33.4.0 in the Android flavor) * @author Colin Decker */ @J2ktIncompatible diff --git a/guava/src/com/google/common/io/RecursiveDeleteOption.java b/guava/src/com/google/common/io/RecursiveDeleteOption.java index a25055e40788..9b47181f0141 100644 --- a/guava/src/com/google/common/io/RecursiveDeleteOption.java +++ b/guava/src/com/google/common/io/RecursiveDeleteOption.java @@ -25,7 +25,7 @@ * Options for use with recursive delete methods ({@link MoreFiles#deleteRecursively} and {@link * MoreFiles#deleteDirectoryContents}). * - * @since 21.0 + * @since 21.0 (but only since 33.4.0 in the Android flavor) * @author Colin Decker */ @J2ktIncompatible