diff --git a/core/src/main/java/hudson/FilePath.java b/core/src/main/java/hudson/FilePath.java index 3ad30c4ac9..15d87c2b11 100644 --- a/core/src/main/java/hudson/FilePath.java +++ b/core/src/main/java/hudson/FilePath.java @@ -1271,7 +1271,7 @@ public final class FilePath implements Serializable { private static final long serialVersionUID = 1L; @Override public Void invoke(File f, VirtualChannel channel) throws IOException { - deleteRecursive(deleting(f)); + Util.deleteRecursive(fileToPath(f), path -> deleting(path.toFile())); return null; } } @@ -1286,34 +1286,11 @@ public final class FilePath implements Serializable { private static final long serialVersionUID = 1L; @Override public Void invoke(File f, VirtualChannel channel) throws IOException { - deleteContentsRecursive(f); + Util.deleteContentsRecursive(fileToPath(f), path -> deleting(path.toFile())); return null; } } - private void deleteRecursive(File dir) throws IOException { - if(!isSymlink(dir)) - deleteContentsRecursive(dir); - try { - deleteFile(deleting(dir)); - } catch (IOException e) { - // if some of the child directories are big, it might take long enough to delete that - // it allows others to create new files, causing problems like JENKINS-10113 - // so give it one more attempt before we give up. - if(!isSymlink(dir)) - deleteContentsRecursive(dir); - deleteFile(deleting(dir)); - } - } - - private void deleteContentsRecursive(File file) throws IOException { - File[] files = file.listFiles(); - if(files==null) - return; // the directory didn't exist in the first place - for (File child : files) - deleteRecursive(child); - } - /** * Gets the file name portion except the extension. * diff --git a/core/src/main/java/hudson/Util.java b/core/src/main/java/hudson/Util.java index 62324734e9..f0c10b4a8e 100644 --- a/core/src/main/java/hudson/Util.java +++ b/core/src/main/java/hudson/Util.java @@ -23,14 +23,13 @@ */ package hudson; -import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; - import hudson.model.TaskListener; import jenkins.util.MemoryReductionUtil; import hudson.util.QuotedStringTokenizer; import hudson.util.VariableResolver; import jenkins.util.SystemProperties; +import jenkins.util.io.PathRemover; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.io.IOUtils; import org.apache.commons.io.output.NullOutputStream; @@ -62,13 +61,11 @@ import java.nio.file.FileSystems; import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.LinkOption; -import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.BasicFileAttributes; -import java.nio.file.attribute.PosixFileAttributes; import java.nio.file.attribute.DosFileAttributes; +import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; import java.security.DigestInputStream; import java.security.MessageDigest; @@ -249,16 +246,18 @@ public class Util { * if the operation fails. */ public static void deleteContentsRecursive(@Nonnull File file) throws IOException { - for( int numberOfAttempts=1 ; ; numberOfAttempts++ ) { - try { - tryOnceDeleteContentsRecursive(file); - break; // success - } catch (IOException ex) { - boolean threadWasInterrupted = pauseBetweenDeletes(numberOfAttempts); - if( numberOfAttempts>= DELETION_MAX || threadWasInterrupted) - throw new IOException(deleteFailExceptionMessage(file, numberOfAttempts, threadWasInterrupted), ex); - } - } + deleteContentsRecursive(fileToPath(file), PathRemover.PathChecker.ALLOW_ALL); + } + + /** + * Deletes the given directory contents (but not the directory itself) recursively using a PathChecker. + * @param path a directory to delete + * @param pathChecker a security check to validate a path before deleting + * @throws IOException if the operation fails + */ + @Restricted(NoExternalUse.class) + public static void deleteContentsRecursive(@Nonnull Path path, @Nonnull PathRemover.PathChecker pathChecker) throws IOException { + newPathRemover(pathChecker).forceRemoveDirectoryContents(path); } /** @@ -269,88 +268,7 @@ public class Util { * @throws IOException if it exists but could not be successfully deleted */ public static void deleteFile(@Nonnull File f) throws IOException { - for( int numberOfAttempts=1 ; ; numberOfAttempts++ ) { - try { - tryOnceDeleteFile(f); - break; // success - } catch (IOException ex) { - boolean threadWasInterrupted = pauseBetweenDeletes(numberOfAttempts); - if( numberOfAttempts>= DELETION_MAX || threadWasInterrupted) - throw new IOException(deleteFailExceptionMessage(f, numberOfAttempts, threadWasInterrupted), ex); - } - } - } - - /** - * Deletes this file, working around most problems which might make - * this difficult. - * - * @param f - * What to delete. If a directory, it'll need to be empty. - * @throws IOException if it exists but could not be successfully deleted, - * or if it represents an invalid {@link Path}. - */ - private static void tryOnceDeleteFile(File f) throws IOException { - Path path = fileToPath(f); - try { - Files.deleteIfExists(path); - } catch (IOException e) { - // perhaps this file is read-only? - makeWritable(path); - /* - on Unix both the file and the directory that contains it has to be writable - for a file deletion to be successful. (Confirmed on Solaris 9) - - $ ls -la - total 6 - dr-xr-sr-x 2 hudson hudson 512 Apr 18 14:41 . - dr-xr-sr-x 3 hudson hudson 512 Apr 17 19:36 .. - -r--r--r-- 1 hudson hudson 469 Apr 17 19:36 manager.xml - -rw-r--r-- 1 hudson hudson 0 Apr 18 14:41 x - $ rm x - rm: x not removed: Permission denied - */ - Path parent = path.getParent(); - if (parent != null) { - makeWritable(parent); - } - try { - Files.deleteIfExists(path); - } catch (IOException e2) { - // see https://java.net/projects/hudson/lists/users/archive/2008-05/message/357 - // I suspect other processes putting files in this directory - File[] files = f.listFiles(); - if(files!=null && files.length>0) - throw new IOException("Unable to delete " + f.getPath()+" - files in dir: "+Arrays.asList(files), e2); - throw e2; - } - } - } - - /** - * Makes the file at the given path writable by any means possible. - */ - private static void makeWritable(@Nonnull Path path) throws IOException { - if (!Functions.isWindows()) { - try { - PosixFileAttributes attrs = Files.readAttributes(path, PosixFileAttributes.class); - Set newPermissions = attrs.permissions(); - newPermissions.add(PosixFilePermission.OWNER_WRITE); - Files.setPosixFilePermissions(path, newPermissions); - return; - } catch (NoSuchFileException e) { - return; - } catch (UnsupportedOperationException e) { - // PosixFileAttributes not supported, fall back to old IO. - } - } - - /** - * We intentionally do not check the return code of setWritable, because if it - * is false we prefer to rethrow the exception thrown by Files.deleteIfExists, - * which will have a more useful message than something we make up here. - */ - path.toFile().setWritable(true); + newPathRemover(PathRemover.PathChecker.ALLOW_ALL).forceRemoveFile(fileToPath(f)); } /** @@ -362,137 +280,18 @@ public class Util { * if the operation fails. */ public static void deleteRecursive(@Nonnull File dir) throws IOException { - for( int numberOfAttempts=1 ; ; numberOfAttempts++ ) { - try { - tryOnceDeleteRecursive(dir); - break; // success - } catch (IOException ex) { - boolean threadWasInterrupted = pauseBetweenDeletes(numberOfAttempts); - if( numberOfAttempts>= DELETION_MAX || threadWasInterrupted) - throw new IOException(deleteFailExceptionMessage(dir, numberOfAttempts, threadWasInterrupted), ex); - } - } + deleteRecursive(fileToPath(dir), PathRemover.PathChecker.ALLOW_ALL); } /** - * Deletes a file or folder, throwing the first exception encountered, but - * having a go at deleting everything. i.e. it does not stop on the - * first exception, but tries (to delete) everything once. - * - * @param dir - * What to delete. If a directory, the contents will be deleted - * too. - * @throws The first exception encountered. + * Deletes the given directory and contents recursively using a filter. + * @param dir a directory to delete + * @param pathChecker a security check to validate a path before deleting + * @throws IOException if the operation fails */ - private static void tryOnceDeleteRecursive(File dir) throws IOException { - if(!isSymlink(dir)) - tryOnceDeleteContentsRecursive(dir); - tryOnceDeleteFile(dir); - } - - /** - * Deletes a folder's contents, throwing the first exception encountered, - * but having a go at deleting everything. i.e. it does not stop - * on the first exception, but tries (to delete) everything once. - * - * @param directory - * The directory whose contents will be deleted. - * @throws The first exception encountered. - */ - private static void tryOnceDeleteContentsRecursive(File directory) throws IOException { - File[] directoryContents = directory.listFiles(); - if(directoryContents==null) - return; // the directory didn't exist in the first place - IOException firstCaught = null; - for (File child : directoryContents) { - try { - tryOnceDeleteRecursive(child); - } catch (IOException justCaught) { - if( firstCaught==null) { - firstCaught = justCaught; - } - } - } - if( firstCaught!=null ) - throw firstCaught; - } - - /** - * Pauses between delete attempts, and says if it's ok to try again. - * This does not wait if the wait time is zero or if we have tried - * too many times already. - *

- * See {@link #WAIT_BETWEEN_DELETION_RETRIES} for details of - * the pause duration.
- * See {@link #GC_AFTER_FAILED_DELETE} for when {@link System#gc()} is called. - * - * @return false if it is ok to continue trying to delete things, true if - * we were interrupted (and should stop now). - */ - @SuppressFBWarnings(value = "DM_GC", justification = "Garbage collection happens only when " - + "GC_AFTER_FAILED_DELETE is true. It's an experimental feature in Jenkins.") - private static boolean pauseBetweenDeletes(int numberOfAttemptsSoFar) { - long delayInMs; - if( numberOfAttemptsSoFar>=DELETION_MAX ) return false; - /* If the Jenkins process had the file open earlier, and it has not - * closed it then Windows won't let us delete it until the Java object - * with the open stream is Garbage Collected, which can result in builds - * failing due to "file in use" on Windows despite working perfectly - * well on other OSs. */ - if (GC_AFTER_FAILED_DELETE) { - System.gc(); - } - if (WAIT_BETWEEN_DELETION_RETRIES>=0) { - delayInMs = WAIT_BETWEEN_DELETION_RETRIES; - } else { - delayInMs = -numberOfAttemptsSoFar*WAIT_BETWEEN_DELETION_RETRIES; - } - if (delayInMs<=0) - return Thread.interrupted(); - try { - Thread.sleep(delayInMs); - return false; - } catch (InterruptedException e) { - return true; - } - } - - /** - * Creates a "couldn't delete file" message that explains how hard we tried. - * See {@link #DELETION_MAX}, {@link #WAIT_BETWEEN_DELETION_RETRIES} - * and {@link #GC_AFTER_FAILED_DELETE} for more details. - */ - private static String deleteFailExceptionMessage(File whatWeWereTryingToRemove, int retryCount, boolean wasInterrupted) { - StringBuilder sb = new StringBuilder(); - sb.append("Unable to delete '"); - sb.append(whatWeWereTryingToRemove); - sb.append("'. Tried "); - sb.append(retryCount); - sb.append(" time"); - if( retryCount!=1 ) sb.append('s'); - if( DELETION_MAX>1 ) { - sb.append(" (of a maximum of "); - sb.append(DELETION_MAX); - sb.append(')'); - if( GC_AFTER_FAILED_DELETE ) - sb.append(" garbage-collecting"); - if( WAIT_BETWEEN_DELETION_RETRIES!=0 && GC_AFTER_FAILED_DELETE ) - sb.append(" and"); - if( WAIT_BETWEEN_DELETION_RETRIES!=0 ) { - sb.append(" waiting "); - sb.append(getTimeSpanString(Math.abs(WAIT_BETWEEN_DELETION_RETRIES))); - if( WAIT_BETWEEN_DELETION_RETRIES<0 ) { - sb.append("-"); - sb.append(getTimeSpanString(Math.abs(WAIT_BETWEEN_DELETION_RETRIES)*DELETION_MAX)); - } - } - if( WAIT_BETWEEN_DELETION_RETRIES!=0 || GC_AFTER_FAILED_DELETE) - sb.append(" between attempts"); - } - if( wasInterrupted ) - sb.append(". The delete operation was interrupted before it completed successfully"); - sb.append('.'); - return sb.toString(); + @Restricted(NoExternalUse.class) + public static void deleteRecursive(@Nonnull Path dir, @Nonnull PathRemover.PathChecker pathChecker) throws IOException { + newPathRemover(pathChecker).forceRemoveRecursive(dir); } /* @@ -511,9 +310,16 @@ public class Util { * limitations under the License. */ /** - * Checks if the given file represents a symlink. + * Checks if the given file represents a symlink. Unlike {@link Files#isSymbolicLink(Path)}, this method also + * considers NTFS junction points as symbolic + * links. */ public static boolean isSymlink(@Nonnull File file) throws IOException { + return isSymlink(fileToPath(file)); + } + + @Restricted(NoExternalUse.class) + public static boolean isSymlink(@Nonnull Path path) { /* * Windows Directory Junctions are effectively the same as Linux symlinks to directories. * Unfortunately, the Java 7 NIO2 API function isSymbolicLink does not treat them as such. @@ -527,20 +333,9 @@ public class Util { * calling readAttributes. */ try { - Path path = fileToPath(file); BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); - if (attrs.isSymbolicLink()) { - return true; - } else if (attrs instanceof DosFileAttributes) { - /* Returns true for non-symbolic link reparse points and devices. We could call - * WindowsFileAttributes#isReparsePoint with reflection instead to exclude devices, - * but as mentioned in the above comment this does not appear to be an issue. - */ - return attrs.isOther(); - } else { - return false; - } - } catch (NoSuchFileException e) { + return attrs.isSymbolicLink() || (attrs instanceof DosFileAttributes && attrs.isOther()); + } catch (IOException ignored) { return false; } } @@ -1812,6 +1607,10 @@ public class Util { @Restricted(value = NoExternalUse.class) static boolean GC_AFTER_FAILED_DELETE = SystemProperties.getBoolean(Util.class.getName() + ".performGCOnFailedDelete"); + private static PathRemover newPathRemover(@Nonnull PathRemover.PathChecker pathChecker) { + return PathRemover.newFilteredRobustRemover(pathChecker, DELETION_MAX - 1, GC_AFTER_FAILED_DELETE, WAIT_BETWEEN_DELETION_RETRIES); + } + /** * If this flag is true, native implementations of {@link FilePath#chmod} * and {@link hudson.util.IOUtils#mode} are used instead of NIO. diff --git a/core/src/main/java/jenkins/util/io/CompositeIOException.java b/core/src/main/java/jenkins/util/io/CompositeIOException.java new file mode 100644 index 0000000000..dfd347cbd6 --- /dev/null +++ b/core/src/main/java/jenkins/util/io/CompositeIOException.java @@ -0,0 +1,74 @@ +/* + * The MIT License + * + * Copyright (c) 2018 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.util.io; + +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.io.PrintStream; +import java.io.PrintWriter; +import java.io.UncheckedIOException; +import java.util.Arrays; +import java.util.List; + +@Restricted(NoExternalUse.class) +public class CompositeIOException extends IOException { + private final List exceptions; + + public CompositeIOException(String message, @Nonnull List exceptions) { + super(message); + this.exceptions = exceptions; + } + + public CompositeIOException(String message, IOException... exceptions) { + this(message, Arrays.asList(exceptions)); + } + + public List getExceptions() { + return exceptions; + } + + @Override + public void printStackTrace(PrintStream s) { + super.printStackTrace(s); + for (IOException exception : exceptions) { + exception.printStackTrace(s); + } + } + + @Override + public void printStackTrace(PrintWriter s) { + super.printStackTrace(s); + for (IOException exception : exceptions) { + exception.printStackTrace(s); + } + } + + public UncheckedIOException asUncheckedIOException() { + return new UncheckedIOException(this); + } +} diff --git a/core/src/main/java/jenkins/util/io/PathRemover.java b/core/src/main/java/jenkins/util/io/PathRemover.java new file mode 100644 index 0000000000..ece2e04bbf --- /dev/null +++ b/core/src/main/java/jenkins/util/io/PathRemover.java @@ -0,0 +1,298 @@ +/* + * The MIT License + * + * Copyright (c) 2018 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.util.io; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import hudson.Functions; +import hudson.Util; +import org.kohsuke.accmod.Restricted; +import org.kohsuke.accmod.restrictions.NoExternalUse; + +import javax.annotation.Nonnull; +import java.io.IOException; +import java.nio.file.DirectoryStream; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFileAttributes; +import java.nio.file.attribute.PosixFilePermission; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Restricted(NoExternalUse.class) +public class PathRemover { + + public static PathRemover newSimpleRemover() { + return new PathRemover(ignored -> false, PathChecker.ALLOW_ALL); + } + + public static PathRemover newRemoverWithStrategy(@Nonnull RetryStrategy retryStrategy) { + return new PathRemover(retryStrategy, PathChecker.ALLOW_ALL); + } + + public static PathRemover newFilteredRobustRemover(@Nonnull PathChecker pathChecker, int maxRetries, boolean gcAfterFailedRemove, long waitBetweenRetries) { + return new PathRemover(new PausingGCRetryStrategy(maxRetries < 1 ? 1 : maxRetries, gcAfterFailedRemove, waitBetweenRetries), pathChecker); + } + + private final RetryStrategy retryStrategy; + private final PathChecker pathChecker; + + private PathRemover(@Nonnull RetryStrategy retryStrategy, @Nonnull PathChecker pathChecker) { + this.retryStrategy = retryStrategy; + this.pathChecker = pathChecker; + } + + public void forceRemoveFile(@Nonnull Path path) throws IOException { + for (int retryAttempts = 0; ; retryAttempts++) { + Optional maybeError = tryRemoveFile(path); + if (!maybeError.isPresent()) return; + if (retryStrategy.shouldRetry(retryAttempts)) continue; + IOException error = maybeError.get(); + throw new IOException(retryStrategy.failureMessage(path, retryAttempts), error); + } + } + + public void forceRemoveDirectoryContents(@Nonnull Path path) throws IOException { + for (int retryAttempt = 0; ; retryAttempt++) { + List errors = tryRemoveDirectoryContents(path); + if (errors.isEmpty()) return; + if (retryStrategy.shouldRetry(retryAttempt)) continue; + throw new CompositeIOException(retryStrategy.failureMessage(path, retryAttempt), errors); + } + } + + public void forceRemoveRecursive(@Nonnull Path path) throws IOException { + for (int retryAttempt = 0; ; retryAttempt++) { + List errors = tryRemoveRecursive(path); + if (errors.isEmpty()) return; + if (retryStrategy.shouldRetry(retryAttempt)) continue; + throw new CompositeIOException(retryStrategy.failureMessage(path, retryAttempt), errors); + } + } + + @Restricted(NoExternalUse.class) + @FunctionalInterface + public interface PathChecker { + void check(@Nonnull Path path) throws SecurityException; + + PathChecker ALLOW_ALL = path -> {}; + } + + @Restricted(NoExternalUse.class) + @FunctionalInterface + public interface RetryStrategy { + boolean shouldRetry(int retriesAttempted); + + default String failureMessage(@Nonnull Path fileToRemove, int retryCount) { + StringBuilder sb = new StringBuilder() + .append("Unable to delete '") + .append(fileToRemove) + .append("'. Tried ") + .append(retryCount) + .append(" time"); + if (retryCount != 1) sb.append('s'); + sb.append('.'); + return sb.toString(); + } + } + + private static class PausingGCRetryStrategy implements RetryStrategy { + private final int maxRetries; + private final boolean gcAfterFailedRemove; + private final long waitBetweenRetries; + private final ThreadLocal interrupted = ThreadLocal.withInitial(() -> false); + + private PausingGCRetryStrategy(int maxRetries, boolean gcAfterFailedRemove, long waitBetweenRetries) { + this.maxRetries = maxRetries; + this.gcAfterFailedRemove = gcAfterFailedRemove; + this.waitBetweenRetries = waitBetweenRetries; + } + + @SuppressFBWarnings(value = "DM_GC", justification = "Garbage collection happens only when " + + "GC_AFTER_FAILED_DELETE is true. It's an experimental feature in Jenkins.") + private void gcIfEnabled() { + /* If the Jenkins process had the file open earlier, and it has not + * closed it then Windows won't let us delete it until the Java object + * with the open stream is Garbage Collected, which can result in builds + * failing due to "file in use" on Windows despite working perfectly + * well on other OSs. */ + if (gcAfterFailedRemove) System.gc(); + } + + @Override + public boolean shouldRetry(int retriesAttempted) { + if (retriesAttempted >= maxRetries) return false; + gcIfEnabled(); + long delayMillis = waitBetweenRetries >= 0 ? waitBetweenRetries : -(retriesAttempted + 1) * waitBetweenRetries; + if (delayMillis <= 0) return !Thread.interrupted(); + try { + Thread.sleep(delayMillis); + return true; + } catch (InterruptedException e) { + interrupted.set(true); + return false; + } + } + + @Override + public String failureMessage(@Nonnull Path fileToRemove, int retryCount) { + StringBuilder sb = new StringBuilder(); + sb.append("Unable to delete '"); + sb.append(fileToRemove); + sb.append("'. Tried "); + sb.append(retryCount + 1); + sb.append(" time"); + if (retryCount != 1) sb.append('s'); + if (maxRetries > 0) { + sb.append(" (of a maximum of "); + sb.append(maxRetries + 1); + sb.append(')'); + if (gcAfterFailedRemove) + sb.append(" garbage-collecting"); + if (waitBetweenRetries != 0 && gcAfterFailedRemove) + sb.append(" and"); + if (waitBetweenRetries != 0) { + sb.append(" waiting "); + sb.append(Util.getTimeSpanString(Math.abs(waitBetweenRetries))); + if (waitBetweenRetries < 0) { + sb.append("-"); + sb.append(Util.getTimeSpanString(Math.abs(waitBetweenRetries) * (maxRetries + 1))); + } + } + if (waitBetweenRetries != 0 || gcAfterFailedRemove) + sb.append(" between attempts"); + } + if (interrupted.get()) + sb.append(". The delete operation was interrupted before it completed successfully"); + sb.append('.'); + interrupted.set(false); + return sb.toString(); + } + } + + private Optional tryRemoveFile(@Nonnull Path path) { + try { + removeOrMakeRemovableThenRemove(path.normalize()); + return Optional.empty(); + } catch (IOException e) { + return Optional.of(e); + } + } + + private List tryRemoveRecursive(@Nonnull Path path) { + Path normalized = path.normalize(); + List accumulatedErrors = Util.isSymlink(normalized) ? new ArrayList<>() : + tryRemoveDirectoryContents(normalized); + tryRemoveFile(normalized).ifPresent(accumulatedErrors::add); + return accumulatedErrors; + } + + private List tryRemoveDirectoryContents(@Nonnull Path path) { + Path normalized = path.normalize(); + List accumulatedErrors = new ArrayList<>(); + if (!Files.isDirectory(normalized)) return accumulatedErrors; + try (DirectoryStream children = Files.newDirectoryStream(normalized)) { + for (Path child : children) { + accumulatedErrors.addAll(tryRemoveRecursive(child)); + } + } catch (IOException e) { + accumulatedErrors.add(e); + } + return accumulatedErrors; + } + + private void removeOrMakeRemovableThenRemove(@Nonnull Path path) throws IOException { + pathChecker.check(path); + try { + Files.deleteIfExists(path); + } catch (IOException e) { + makeRemovable(path); + try { + Files.deleteIfExists(path); + } catch (IOException e2) { + // see https://java.net/projects/hudson/lists/users/archive/2008-05/message/357 + // I suspect other processes putting files in this directory + if (Files.isDirectory(path)) { + List entries; + try (Stream children = Files.list(path)) { + entries = children.map(Path::toString).collect(Collectors.toList()); + } + throw new CompositeIOException("Unable to remove directory " + path + " with directory contents: " + entries, e, e2); + } + throw new CompositeIOException("Unable to remove file " + path, e, e2); + } + } + } + + private static void makeRemovable(@Nonnull Path path) throws IOException { + if (!Files.isWritable(path)) { + makeWritable(path); + } + /* + on Unix both the file and the directory that contains it has to be writable + for a file deletion to be successful. (Confirmed on Solaris 9) + + $ ls -la + total 6 + dr-xr-sr-x 2 hudson hudson 512 Apr 18 14:41 . + dr-xr-sr-x 3 hudson hudson 512 Apr 17 19:36 .. + -r--r--r-- 1 hudson hudson 469 Apr 17 19:36 manager.xml + -rw-r--r-- 1 hudson hudson 0 Apr 18 14:41 x + $ rm x + rm: x not removed: Permission denied + */ + Optional maybeParent = Optional.ofNullable(path.getParent()).map(Path::normalize).filter(p -> !Files.isWritable(p)); + if (maybeParent.isPresent()) { + makeWritable(maybeParent.get()); + } + } + + private static void makeWritable(@Nonnull Path path) throws IOException { + if (!Functions.isWindows()) { + try { + PosixFileAttributes attrs = Files.readAttributes(path, PosixFileAttributes.class); + Set newPermissions = attrs.permissions(); + newPermissions.add(PosixFilePermission.OWNER_WRITE); + Files.setPosixFilePermissions(path, newPermissions); + } catch (NoSuchFileException ignored) { + return; + } catch (UnsupportedOperationException ignored) { + // PosixFileAttributes not supported, fall back to old IO. + } + } + + /* + * We intentionally do not check the return code of setWritable, because if it + * is false we prefer to rethrow the exception thrown by Files.deleteIfExists, + * which will have a more useful message than something we make up here. + */ + path.toFile().setWritable(true); + } + +} diff --git a/core/src/test/java/hudson/FilePathTest.java b/core/src/test/java/hudson/FilePathTest.java index 593d30ce4a..b40d0a37d4 100644 --- a/core/src/test/java/hudson/FilePathTest.java +++ b/core/src/test/java/hudson/FilePathTest.java @@ -26,6 +26,7 @@ package hudson; import hudson.FilePath.TarCompression; import hudson.model.TaskListener; import hudson.os.PosixAPI; +import hudson.os.WindowsUtil; import hudson.remoting.VirtualChannel; import hudson.util.NullStream; import hudson.util.StreamTaskListener; @@ -836,17 +837,14 @@ public class FilePathTest { Path targetDir = temp.newFolder("targetDir").toPath(); Path targetContents = Files.createFile(targetDir.resolve("contents.txt")); Path toDelete = temp.newFolder("toDelete").toPath(); - Process p = new ProcessBuilder() - .directory(toDelete.toFile()) - .command("cmd.exe", "/C", "mklink /J junction ..\\targetDir") - .start(); - assumeThat("unable to create junction", p.waitFor(), is(0)); + File junction = WindowsUtil.createJunction(toDelete.resolve("junction").toFile(), targetDir.toFile()); Files.createFile(toDelete.resolve("foo")); Files.createFile(toDelete.resolve("bar")); FilePath f = new FilePath(toDelete.toFile()); f.deleteRecursive(); assertTrue("junction target should not be deleted", Files.exists(targetDir)); assertTrue("junction target contents should not be deleted", Files.exists(targetContents)); + assertFalse("could not delete junction", junction.exists()); assertFalse("could not delete target", Files.exists(toDelete)); } diff --git a/core/src/test/java/hudson/RemoveWindowsDirectoryJunctionTest.java b/core/src/test/java/hudson/RemoveWindowsDirectoryJunctionTest.java index cd631503c3..d0255f6f37 100644 --- a/core/src/test/java/hudson/RemoveWindowsDirectoryJunctionTest.java +++ b/core/src/test/java/hudson/RemoveWindowsDirectoryJunctionTest.java @@ -3,18 +3,22 @@ */ package hudson; -import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; import static org.junit.Assume.assumeTrue; import java.io.File; + +import hudson.os.WindowsUtil; import org.junit.Before; import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.jvnet.hudson.test.For; import org.jvnet.hudson.test.Issue; +@For(Util.class) +// https://superuser.com/q/343074 public class RemoveWindowsDirectoryJunctionTest { @Rule public TemporaryFolder tmp = new TemporaryFolder(); @@ -30,19 +34,10 @@ public class RemoveWindowsDirectoryJunctionTest { File subdir1 = tmp.newFolder("notJunction"); File f1 = new File(subdir1, "testfile1.txt"); assertTrue("Unable to create temporary file in notJunction directory", f1.createNewFile()); - File j1 = makeJunction(tmp.getRoot(), subdir1); + File j1 = WindowsUtil.createJunction(new File(tmp.getRoot(), "test junction"), subdir1); Util.deleteRecursive(j1); assertFalse("Windows Junction should have been removed", j1.exists()); assertTrue("Contents of Windows Junction should not be removed", f1.exists()); } - private File makeJunction(File baseDir, File pointToDir) throws Exception { - File junc = new File(baseDir, "test Junction"); - String cmd = "mklink /J \"" + junc.getPath() + "\" \"" + pointToDir.getPath() + "\""; - ProcessBuilder pb = new ProcessBuilder("cmd.exe", "/C", cmd); - pb.inheritIO(); - Process p = pb.start(); - assertEquals("Running mklink failed (cmd=" + cmd + ")", 0, p.waitFor()); - return junc; - } } diff --git a/core/src/test/java/hudson/UtilTest.java b/core/src/test/java/hudson/UtilTest.java index 1002ac70a3..8662fc5631 100644 --- a/core/src/test/java/hudson/UtilTest.java +++ b/core/src/test/java/hudson/UtilTest.java @@ -24,41 +24,31 @@ */ package hudson; +import java.nio.file.Files; +import java.nio.file.Path; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; -import java.util.List; import java.util.Map; import java.util.HashMap; import java.util.Locale; import java.util.Properties; -import java.util.concurrent.Callable; -import java.util.concurrent.atomic.AtomicReference; import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.FileInputStream; import java.io.IOException; -import java.io.InputStream; -import java.nio.file.FileSystemException; -import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermissions; -import static org.hamcrest.CoreMatchers.allOf; -import static org.hamcrest.CoreMatchers.containsString; -import static org.hamcrest.CoreMatchers.hasItem; -import static org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.startsWith; import static org.junit.Assert.*; -import org.apache.commons.io.FileUtils; +import hudson.os.WindowsUtil; import org.hamcrest.BaseMatcher; import org.hamcrest.Description; import org.junit.Assume; -import org.junit.Ignore; import org.junit.Test; +import org.junit.rules.ExpectedException; import org.jvnet.hudson.test.Issue; import hudson.util.StreamTaskListener; @@ -66,8 +56,6 @@ import hudson.util.StreamTaskListener; import org.junit.Rule; import org.junit.rules.TemporaryFolder; -import com.google.common.collect.Lists; - /** * @author Kohsuke Kawaguchi */ @@ -75,6 +63,8 @@ public class UtilTest { @Rule public TemporaryFolder tmp = new TemporaryFolder(); + @Rule public ExpectedException expectedException = ExpectedException.none(); + @Test public void testReplaceMacro() { Map m = new HashMap(); @@ -192,7 +182,7 @@ public class UtilTest { @Test public void testSymlink() throws Exception { - Assume.assumeTrue(!Functions.isWindows()); + Assume.assumeFalse(Functions.isWindows()); ByteArrayOutputStream baos = new ByteArrayOutputStream(); StreamTaskListener l = new StreamTaskListener(baos); @@ -240,7 +230,7 @@ public class UtilTest { @Test public void testIsSymlink() throws IOException, InterruptedException { - Assume.assumeTrue(!Functions.isWindows()); + Assume.assumeFalse(Functions.isWindows()); ByteArrayOutputStream baos = new ByteArrayOutputStream(); StreamTaskListener l = new StreamTaskListener(baos); @@ -271,292 +261,36 @@ public class UtilTest { @Test public void testIsSymlink_onWindows_junction() throws Exception { Assume.assumeTrue("Uses Windows-specific features", Functions.isWindows()); - tmp.newFolder("targetDir"); + File targetDir = tmp.newFolder("targetDir"); File d = tmp.newFolder("dir"); - Process p = new ProcessBuilder() - .directory(d) - .command("cmd.exe", "/C", "mklink /J junction ..\\targetDir") - .start(); - Assume.assumeThat("unable to create junction", p.waitFor(), is(0)); - assertTrue(Util.isSymlink(new File(d, "junction"))); + File junction = WindowsUtil.createJunction(new File(d, "junction"), targetDir); + assertTrue(Util.isSymlink(junction)); } @Test - public void testDeleteFile() throws Exception { - File f = tmp.newFile(); - // Test: File is deleted - mkfiles(f); - Util.deleteFile(f); - assertFalse("f exists after calling Util.deleteFile", f.exists()); + @Issue("JENKINS-55448") + public void testIsSymlink_ParentIsJunction() throws IOException, InterruptedException { + Assume.assumeTrue("Uses Windows-specific features", Functions.isWindows()); + File targetDir = tmp.newFolder(); + File file = new File(targetDir, "test-file"); + new FilePath(file).touch(System.currentTimeMillis()); + File dir = tmp.newFolder(); + File junction = WindowsUtil.createJunction(new File(dir, "junction"), targetDir); + + assertTrue(Util.isSymlink(junction)); + assertFalse(Util.isSymlink(file)); } @Test - public void testDeleteFile_onWindows() throws Exception { - Assume.assumeTrue(Functions.isWindows()); - final int defaultDeletionMax = Util.DELETION_MAX; - try { - File f = tmp.newFile(); - // Test: If we cannot delete a file, we throw explaining why - mkfiles(f); - lockFileForDeletion(f); - Util.DELETION_MAX = 1; - try { - Util.deleteFile(f); - fail("should not have been deletable"); - } catch (IOException x) { - assertThat(calcExceptionHierarchy(x), hasItem(FileSystemException.class)); - assertThat(x.getMessage(), containsString(f.getPath())); - } - } finally { - Util.DELETION_MAX = defaultDeletionMax; - unlockFilesForDeletion(); - } - } - - @Test - public void testDeleteFileReadOnly() throws Exception { - // Removing the calls to Util#makeWritable in Util#tryOnceDeleteFile should cause this test to fail. - Path file = tmp.newFolder().toPath().resolve("file.tmp"); - Files.createDirectories(file.getParent()); - Files.createFile(file); - // Using old IO so the test can run on Windows. - file.getParent().toFile().setWritable(false); - file.toFile().setWritable(false); - Util.deleteFile(file.toFile()); - assertFalse(Files.exists(file)); - } - - @Test - public void testDeleteFileDoesNotExist() throws Exception { - Path file = tmp.newFolder().toPath().resolve("file.tmp"); - assertFalse(Files.exists(file)); - // Should not throw an exception. - Util.deleteFile(file.toFile()); - } - - @Test - public void testDeleteContentsRecursive() throws Exception { - final File dir = tmp.newFolder(); - final File d1 = new File(dir, "d1"); - final File d2 = new File(dir, "d2"); - final File f1 = new File(dir, "f1"); - final File d1f1 = new File(d1, "d1f1"); - final File d2f2 = new File(d2, "d1f2"); - // Test: Files and directories are deleted - mkdirs(dir, d1, d2); - mkfiles(f1, d1f1, d2f2); - Util.deleteContentsRecursive(dir); - assertTrue("dir exists", dir.exists()); - assertFalse("d1 exists", d1.exists()); - assertFalse("d2 exists", d2.exists()); - assertFalse("f1 exists", f1.exists()); - } - - @Ignore("TODO often fails in CI") - @Test - public void testDeleteContentsRecursive_onWindows() throws Exception { - Assume.assumeTrue(Functions.isWindows()); - final File dir = tmp.newFolder(); - final File d1 = new File(dir, "d1"); - final File d2 = new File(dir, "d2"); - final File f1 = new File(dir, "f1"); - final File d1f1 = new File(d1, "d1f1"); - final File d2f2 = new File(d2, "d1f2"); - final int defaultDeletionMax = Util.DELETION_MAX; - final int defaultDeletionWait = Util.WAIT_BETWEEN_DELETION_RETRIES; - final boolean defaultDeletionGC = Util.GC_AFTER_FAILED_DELETE; - try { - // Test: If we cannot delete a file, we throw - // but still deletes everything it can - // even if we are not retrying deletes. - mkdirs(dir, d1, d2); - mkfiles(f1, d1f1, d2f2); - lockFileForDeletion(d1f1); - Util.GC_AFTER_FAILED_DELETE = false; - Util.DELETION_MAX = 2; - Util.WAIT_BETWEEN_DELETION_RETRIES = 0; - try { - Util.deleteContentsRecursive(dir); - fail("Expected IOException"); - } catch (IOException x) { - assertFalse("d2 should not exist", d2.exists()); - assertFalse("f1 should not exist", f1.exists()); - assertFalse("d1f2 should not exist", d2f2.exists()); - assertThat(x.getMessage(), containsString(dir.getPath())); - assertThat(x.getMessage(), allOf(not(containsString("interrupted")), containsString("Tried 2 times (of a maximum of 2)."), not(containsString("garbage-collecting")), not(containsString("wait")))); - } - } finally { - Util.DELETION_MAX = defaultDeletionMax; - Util.WAIT_BETWEEN_DELETION_RETRIES = defaultDeletionWait; - Util.GC_AFTER_FAILED_DELETE = defaultDeletionGC; - unlockFilesForDeletion(); - } - } - - @Test - public void testDeleteRecursive() throws Exception { - final File dir = tmp.newFolder(); - final File d1 = new File(dir, "d1"); - final File d2 = new File(dir, "d2"); - final File f1 = new File(dir, "f1"); - final File d1f1 = new File(d1, "d1f1"); - final File d2f2 = new File(d2, "d1f2"); - // Test: Files and directories are deleted - mkdirs(dir, d1, d2); - mkfiles(f1, d1f1, d2f2); - Util.deleteRecursive(dir); - assertFalse("dir exists", dir.exists()); - } - - @Ignore("JENKINS-55016") - @Test - public void testDeleteRecursive_onWindows() throws Exception { - Assume.assumeTrue(Functions.isWindows()); - final File dir = tmp.newFolder(); - final File d1 = new File(dir, "d1"); - final File d2 = new File(dir, "d2"); - final File f1 = new File(dir, "f1"); - final File d1f1 = new File(d1, "d1f1"); - final File d2f2 = new File(d2, "d1f2"); - final int defaultDeletionMax = Util.DELETION_MAX; - final int defaultDeletionWait = Util.WAIT_BETWEEN_DELETION_RETRIES; - final boolean defaultDeletionGC = Util.GC_AFTER_FAILED_DELETE; - try { - // Test: If we cannot delete a file, we throw - // but still deletes everything it can - // even if we are not retrying deletes. - // (And when we are not retrying deletes, - // we do not do the "between retries" delay) - mkdirs(dir, d1, d2); - mkfiles(f1, d1f1, d2f2); - lockFileForDeletion(d1f1); - Util.GC_AFTER_FAILED_DELETE = false; - Util.DELETION_MAX = 1; - Util.WAIT_BETWEEN_DELETION_RETRIES = 10000; // long enough to notice - long timeWhenDeletionStarted = System.currentTimeMillis(); - try { - Util.deleteRecursive(dir); - fail("Expected IOException"); - } catch (IOException x) { - long timeWhenDeletionEnded = System.currentTimeMillis(); - assertTrue("dir exists", dir.exists()); - assertTrue("d1 exists", d1.exists()); - assertTrue("d1f1 exists", d1f1.exists()); - assertFalse("d2 should not exist", d2.exists()); - assertFalse("f1 should not exist", f1.exists()); - assertFalse("d1f2 should not exist", d2f2.exists()); - assertThat(x.getMessage(), containsString(dir.getPath())); - assertThat(x.getMessage(), allOf(not(containsString("interrupted")), not(containsString("maximum of")), not(containsString("garbage-collecting")))); - long actualTimeSpentDeleting = timeWhenDeletionEnded - timeWhenDeletionStarted; - assertTrue("did not wait - took " + actualTimeSpentDeleting + "ms", actualTimeSpentDeleting<1000L); - } - unlockFileForDeletion(d1f1); - // Deletes get retried if they fail 1st time around, - // allowing the operation to succeed on subsequent attempts. - // Note: This is what bug JENKINS-15331 is all about. - mkdirs(dir, d1, d2); - mkfiles(f1, d1f1, d2f2); - lockFileForDeletion(d2f2); - Util.DELETION_MAX=4; - Util.WAIT_BETWEEN_DELETION_RETRIES = 100; - Thread unlockAfterDelay = new Thread("unlockFileAfterDelay") { - public void run() { - try { - Thread.sleep(Util.WAIT_BETWEEN_DELETION_RETRIES); - unlockFileForDeletion(d2f2); - } catch( Exception x ) { /* ignored */ } - } - }; - unlockAfterDelay.start(); - Util.deleteRecursive(dir); - assertFalse("dir should have been deleted", dir.exists()); - unlockAfterDelay.join(); - // An interrupt aborts the delete and makes it fail, even - // if we had been told to retry a lot. - mkdirs(dir, d1, d2); - mkfiles(f1, d1f1, d2f2); - lockFileForDeletion(d1f1); - Util.DELETION_MAX=10; - Util.WAIT_BETWEEN_DELETION_RETRIES = -1000; - Util.GC_AFTER_FAILED_DELETE = true; - final AtomicReference thrown = new AtomicReference(); - Thread deleteToBeInterrupted = new Thread("deleteToBeInterrupted") { - public void run() { - try { Util.deleteRecursive(dir); } - catch( Throwable x ) { thrown.set(x); } - } - }; - deleteToBeInterrupted.start(); - deleteToBeInterrupted.interrupt(); - deleteToBeInterrupted.join(500); - assertFalse("deletion stopped", deleteToBeInterrupted.isAlive()); - assertTrue("d1f1 still exists", d1f1.exists()); - unlockFileForDeletion(d1f1); - Throwable deletionInterruptedEx = thrown.get(); - assertThat(deletionInterruptedEx, instanceOf(IOException.class)); - assertThat(deletionInterruptedEx.getMessage(), allOf(containsString("interrupted"), containsString("maximum of " + Util.DELETION_MAX), containsString("garbage-collecting"))); - } finally { - Util.DELETION_MAX = defaultDeletionMax; - Util.WAIT_BETWEEN_DELETION_RETRIES = defaultDeletionWait; - Util.GC_AFTER_FAILED_DELETE = defaultDeletionGC; - unlockFilesForDeletion(); - } - } - - /** Creates multiple directories. */ - private static void mkdirs(File... dirs) { - for( File d : dirs ) { - d.mkdir(); - assertTrue(d.getPath(), d.isDirectory()); - } - } - - /** Creates multiple files, each containing their filename as text content. */ - private static void mkfiles(File... files) throws IOException { - for( File f : files ) - FileUtils.write(f, f.getName()); - } - - /** Means of unlocking all the files we have locked, indexed by {@link File}. */ - private final Map> unlockFileCallables = new HashMap>(); - - /** Prevents a file from being deleted, so we can stress the deletion code's retries. */ - private void lockFileForDeletion(File f) throws IOException, InterruptedException { - assert !unlockFileCallables.containsKey(f) : f + " is already locked." ; - // Limitation: Only works on Windows. On unix we can delete anything we can create. - // On unix, can't use "chmod a-w" on the dir as the code-under-test undoes that. - // On unix, can't use "chattr +i" because that needs root. - // On unix, can't use "chattr +u" because ext fs ignores it. - // On Windows, can't use FileChannel.lock() because that doesn't block deletion - // On Windows, we can't delete files that are open for reading, so we use that. - // NOTE: This is a hack in any case as there is no guarantee that all Windows filesystems - // will enforce blocking deletion on open files... just that the ones we normally - // test with seem to block. - assert Functions.isWindows(); - final InputStream s = new FileInputStream(f); // intentional use of FileInputStream - unlockFileCallables.put(f, new Callable() { - public Void call() throws IOException { s.close(); return null; }; - }); - } - - /** Undoes a call to {@link #lockFileForDeletion(File)}. */ - private void unlockFileForDeletion(File f) throws Exception { - unlockFileCallables.remove(f).call(); - } - - /** Undoes all calls to {@link #lockFileForDeletion(File)}. */ - private void unlockFilesForDeletion() throws Exception { - while( !unlockFileCallables.isEmpty() ) { - unlockFileForDeletion(unlockFileCallables.keySet().iterator().next()); - } - } - - /** Returns all classes in the exception hierarchy. */ - private static Iterable> calcExceptionHierarchy(Throwable t) { - final List> result = Lists.newArrayList(); - for( ; t!=null ; t = t.getCause()) - result.add(t.getClass()); - return result; + @Issue("JENKINS-55448") + public void testIsSymlink_ParentIsSymlink() throws IOException, InterruptedException { + File folder = tmp.newFolder(); + File file = new File(folder, "test-file"); + new FilePath(file).touch(System.currentTimeMillis()); + Path link = tmp.getRoot().toPath().resolve("sym-link"); + Path pathWithSymlinkParent = Files.createSymbolicLink(link, folder.toPath()).resolve("test-file"); + assertTrue(Util.isSymlink(link)); + assertFalse(Util.isSymlink(pathWithSymlinkParent)); } @Test @@ -764,12 +498,8 @@ public class UtilTest { assertEquals("Non-permission bits should be ignored", PosixFilePermissions.fromString("r-xr-----"), Util.modeToPermissions(0100540)); - try { - Util.modeToPermissions(01777); - fail("Did not detect invalid mode"); - } catch (IOException e) { - assertThat(e.getMessage(), startsWith("Invalid mode")); - } + expectedException.expectMessage(startsWith("Invalid mode")); + Util.modeToPermissions(01777); } @Test diff --git a/core/src/test/java/hudson/os/WindowsUtil.java b/core/src/test/java/hudson/os/WindowsUtil.java new file mode 100644 index 0000000000..6e528827a3 --- /dev/null +++ b/core/src/test/java/hudson/os/WindowsUtil.java @@ -0,0 +1,117 @@ +/* + * The MIT License + * + * Copyright (c) 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package hudson.os; + +import hudson.Functions; +import org.apache.commons.io.IOUtils; + +import javax.annotation.Nonnull; +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertTrue; + +// adapted from: +// https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ +public class WindowsUtil { + private static final Pattern NEEDS_QUOTING = Pattern.compile("[\\s\"]"); + + /** + * Quotes an argument while escaping special characters interpreted by CreateProcess. + */ + public static @Nonnull String quoteArgument(@Nonnull String argument) { + if (!NEEDS_QUOTING.matcher(argument).find()) return argument; + StringBuilder sb = new StringBuilder(); + sb.append('"'); + int end = argument.length(); + for (int i = 0; i < end; i++) { + int nrBackslashes = 0; + while (i < end && argument.charAt(i) == '\\') { + i++; + nrBackslashes++; + } + + if (i == end) { + // backslashes at the end of the argument must be escaped so the terminate quote isn't + nrBackslashes = nrBackslashes * 2; + } else if (argument.charAt(i) == '"') { + // backslashes preceding a quote all need to be escaped along with the quote + nrBackslashes = nrBackslashes * 2 + 1; + } + // else backslashes have no special meaning and don't need to be escaped here + + for (int j = 0; j < nrBackslashes; j++) { + sb.append('\\'); + } + + if (i < end) { + sb.append(argument.charAt(i)); + } + } + return sb.append('"').toString(); + } + + private static final Pattern CMD_METACHARS = Pattern.compile("[()%!^\"<>&|]"); + + /** + * Quotes an argument while escaping special characters suitable for use as an argument to {@code cmd.exe}. + */ + public static @Nonnull String quoteArgumentForCmd(@Nonnull String argument) { + return CMD_METACHARS.matcher(quoteArgument(argument)).replaceAll("^$0"); + } + + /** + * Executes a command and arguments using {@code cmd.exe /C ...}. + */ + public static @Nonnull Process execCmd(String... argv) throws IOException { + String command = Arrays.stream(argv).map(WindowsUtil::quoteArgumentForCmd).collect(Collectors.joining(" ")); + return Runtime.getRuntime().exec(new String[]{"cmd.exe", "/C", command}); + } + + /** + * Creates an NTFS junction point if supported. Similar to symbolic links, NTFS provides junction points which + * provide different features than symbolic links. + * @param junction NTFS junction point to create + * @param target target directory to junction + * @return the newly created junction point + * @throws IOException if the call to mklink exits with a non-zero status code + * @throws InterruptedException if the call to mklink is interrupted before completing + * @throws AssertionError if this method is called on a non-Windows platform + */ + public static @Nonnull File createJunction(@Nonnull File junction, @Nonnull File target) throws IOException, InterruptedException { + assertTrue(Functions.isWindows()); + Process mklink = execCmd("mklink", "/J", junction.getAbsolutePath(), target.getAbsolutePath()); + int result = mklink.waitFor(); + if (result != 0) { + String stderr = IOUtils.toString(mklink.getErrorStream()); + String stdout = IOUtils.toString(mklink.getInputStream()); + throw new IOException("Process exited with " + result + "\nStandard Output:\n" + stdout + "\nError Output:\n" + stderr); + } + return junction; + } +} diff --git a/core/src/test/java/hudson/os/WindowsUtilTest.java b/core/src/test/java/hudson/os/WindowsUtilTest.java new file mode 100644 index 0000000000..c8b4dbb4bf --- /dev/null +++ b/core/src/test/java/hudson/os/WindowsUtilTest.java @@ -0,0 +1,66 @@ +/* + * The MIT License + * + * Copyright (c) 2019 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package hudson.os; + +import org.junit.Test; + +import java.util.Arrays; + +import static org.junit.Assert.*; + +/** + * Test the test utility code, too! + */ +public class WindowsUtilTest { + @Test + public void testQuoteArgument() { + String input = "C:\\Programs and \"Settings\"\\System32\\\\"; + String expected = "\"C:\\Programs and \\\"Settings\\\"\\System32\\\\\\\\\""; + String actual = WindowsUtil.quoteArgument(input); + assertEquals(expected, actual); + } + + @Test + public void testQuoteArgument_OnlyQuotesWhenNecessary() { + for (String arg : Arrays.asList("", "foo", "foo-bar", "C:\\test\\path", "http://www.example.com/")) { + assertEquals(arg, WindowsUtil.quoteArgument(arg)); + } + } + + @Test + public void testQuoteArgumentForCmd() { + String input = "hello \"\\world&"; + String expected = "^\"hello \\^\"\\world^&^\""; + String actual = WindowsUtil.quoteArgumentForCmd(input); + assertEquals(expected, actual); + } + + @Test + public void testQuoteArgumentForCmd_OnlyQuotesWhenNecessary() { + for (String arg : Arrays.asList("", "foo", "foo-bar", "C:\\test\\path", "http://www.example.com/")) { + assertEquals(arg, WindowsUtil.quoteArgumentForCmd(arg)); + } + } +} \ No newline at end of file diff --git a/core/src/test/java/jenkins/util/io/FileLockerRule.java b/core/src/test/java/jenkins/util/io/FileLockerRule.java new file mode 100644 index 0000000000..df0dab652e --- /dev/null +++ b/core/src/test/java/jenkins/util/io/FileLockerRule.java @@ -0,0 +1,80 @@ +/* + * The MIT License + * + * Copyright (c) 2018 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.util.io; + +import hudson.Functions; +import org.junit.rules.ExternalResource; + +import javax.annotation.Nonnull; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; +import java.util.Map; + +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.assertTrue; + +/** + * Helper class for tracking which files are locked. + * Only useful in Windows environments as POSIX seems to allow locked files to be deleted. + */ +public class FileLockerRule extends ExternalResource { + private final Map locks = new HashMap<>(); + + @Override + protected void after() { + List exceptions = new ArrayList<>(); + Iterator it = locks.values().iterator(); + while (it.hasNext()) { + try (Closeable ignored = it.next()) { + it.remove(); + } catch (IOException e) { + exceptions.add(e); + } + } + if (!exceptions.isEmpty()) + throw new CompositeIOException("Could not unlock all files", exceptions).asUncheckedIOException(); + } + + public synchronized void acquireLock(@Nonnull File file) throws IOException { + assertTrue(Functions.isWindows()); + assertThat(file + " is already locked.", locks, not(hasKey(file))); + Closeable lock = new FileInputStream(file); + locks.put(file, lock); + } + + public synchronized void releaseLock(@Nonnull File file) throws Exception { + assertTrue(Functions.isWindows()); + assertThat(file + " is not locked.", locks, hasKey(file)); + locks.remove(file).close(); + } +} diff --git a/core/src/test/java/jenkins/util/io/PathRemoverTest.java b/core/src/test/java/jenkins/util/io/PathRemoverTest.java new file mode 100644 index 0000000000..860315f6fc --- /dev/null +++ b/core/src/test/java/jenkins/util/io/PathRemoverTest.java @@ -0,0 +1,437 @@ +/* + * The MIT License + * + * Copyright (c) 2018 CloudBees, Inc. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + */ + +package jenkins.util.io; + +import hudson.Functions; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.junit.rules.TemporaryFolder; +import org.junit.rules.Timeout; +import org.jvnet.hudson.test.Issue; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.nio.file.FileSystem; +import java.nio.file.FileSystemException; +import java.nio.file.Files; +import java.nio.file.LinkOption; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.spi.FileSystemProvider; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.hasItem; +import static org.junit.Assert.*; +import static org.junit.Assume.assumeTrue; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +public class PathRemoverTest { + + @Rule public TemporaryFolder tmp = new TemporaryFolder(); + @Rule public ExpectedException expectedException = ExpectedException.none(); + @Rule public Timeout timeout = new Timeout(10, TimeUnit.SECONDS); + @Rule public FileLockerRule locker = new FileLockerRule(); + + @Test + public void testForceRemoveFile() throws IOException { + File file = tmp.newFile(); + touchWithFileName(file); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveFile(file.toPath()); + + assertFalse("Unable to delete file: " + file, file.exists()); + } + + @Test + public void testForceRemoveFile_LockedFile() throws Exception { + String filename = "/var/lock/jenkins.lock"; + File file = mock(File.class); + Path path = mock(Path.class); + FileSystem fs = mock(FileSystem.class); + FileSystemProvider fsProvider = mock(FileSystemProvider.class); + BasicFileAttributes attributes = mock(BasicFileAttributes.class); + + given(file.getPath()).willReturn(filename); + given(file.toPath()).willReturn(path); + given(path.toString()).willReturn(filename); + given(path.toFile()).willReturn(file); + given(path.getFileSystem()).willReturn(fs); + given(path.normalize()).willReturn(path); + given(fs.provider()).willReturn(fsProvider); + given(fsProvider.deleteIfExists(path)).willThrow(new FileSystemException(filename)); + given(fsProvider.readAttributes(path, BasicFileAttributes.class)).willReturn(attributes); + given(attributes.isDirectory()).willReturn(false); + + PathRemover remover = PathRemover.newSimpleRemover(); + try { + remover.forceRemoveFile(file.toPath()); + fail("Should not have been deleted: " + file); + } catch (IOException e) { + assertThat(calcExceptionHierarchy(e), hasItem(FileSystemException.class)); + assertThat(e.getMessage(), containsString(filename)); + } + + } + + private static List> calcExceptionHierarchy(Throwable t) { + List> hierarchy = new ArrayList<>(); + for (; t != null; t = t.getCause()) { + hierarchy.add(t.getClass()); + // with a composite exception, we might be hiding some other causes + if (t instanceof CompositeIOException) { + CompositeIOException e = (CompositeIOException) t; + e.getExceptions().forEach(ex -> hierarchy.addAll(calcExceptionHierarchy(ex))); + } + } + return hierarchy; + } + + @Test + public void testForceRemoveFile_ReadOnly() throws IOException { + File dir = tmp.newFolder(); + File file = new File(dir, "file.tmp"); + touchWithFileName(file); + assertTrue("Unable to make file read-only: " + file, file.setWritable(false)); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveFile(file.toPath()); + + assertFalse("Unable to delete file: " + file, file.exists()); + } + + @Test + public void testForceRemoveFile_DoesNotExist() throws IOException { + File dir = tmp.newFolder(); + File file = new File(dir, "invalid.file"); + assertFalse(file.exists()); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveFile(file.toPath()); + + assertFalse("Unable to delete file: " + file, file.exists()); + } + + @Test + public void testForceRemoveFile_SymbolicLink() throws IOException { + File file = tmp.newFile(); + touchWithFileName(file); + Path link = Files.createSymbolicLink(tmp.getRoot().toPath().resolve("test-link"), file.toPath()); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveFile(link); + + assertTrue("Unable to delete symbolic link: " + link, Files.notExists(link, LinkOption.NOFOLLOW_LINKS)); + assertTrue("Should not have deleted target file: " + file, file.exists()); + } + + @Test + @Issue("JENKINS-55448") + public void testForceRemoveFile_DotsInPath() throws IOException { + Path folder = tmp.newFolder().toPath(); + File test = tmp.newFile("test"); + touchWithFileName(test); + Path path = folder.resolve("../test"); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveFile(path); + + assertTrue("Unable to delete file: " + path, Files.notExists(path)); + assertFalse(test.exists()); + assertTrue("Should not have deleted directory: " + folder, Files.exists(folder)); + } + + @Test + @Issue("JENKINS-55448") + public void testForceRemoveFile_ParentIsSymbolicLink() throws IOException { + Path realParent = tmp.newFolder().toPath(); + Path path = realParent.resolve("test-file"); + touchWithFileName(path.toFile()); + Path symParent = Files.createSymbolicLink(tmp.getRoot().toPath().resolve("sym-parent"), realParent); + Path toDelete = symParent.resolve("test-file"); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveFile(toDelete); + + assertTrue("Unable to delete file: " + toDelete, Files.notExists(toDelete)); + assertTrue("Should not have deleted directory: " + realParent, Files.exists(realParent)); + assertTrue("Should not have deleted symlink: " + symParent, Files.exists(symParent, LinkOption.NOFOLLOW_LINKS)); + } + + @Test + public void testForceRemoveDirectoryContents() throws IOException { + File dir = tmp.newFolder(); + File d1 = new File(dir, "d1"); + File d2 = new File(dir, "d2"); + File f1 = new File(dir, "f1"); + File d1f1 = new File(d1, "d1f1"); + File d2f2 = new File(d2, "d1f2"); + mkdirs(d1, d2); + touchWithFileName(f1, d1f1, d2f2); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveDirectoryContents(dir.toPath()); + + assertTrue(dir.exists()); + assertFalse(d1.exists()); + assertFalse(d2.exists()); + assertFalse(f1.exists()); + } + + @Test + public void testForceRemoveDirectoryContents_LockedFile() throws Exception { + assumeTrue(Functions.isWindows()); + File dir = tmp.newFolder(); + File d1 = new File(dir, "d1"); + File d2 = new File(dir, "d2"); + File f1 = new File(dir, "f1"); + File d1f1 = new File(d1, "d1f1"); + File d2f2 = new File(d2, "d1f2"); + mkdirs(d1, d2); + touchWithFileName(f1, d1f1, d2f2); + locker.acquireLock(d1f1); + PathRemover remover = PathRemover.newRemoverWithStrategy(retriesAttempted -> retriesAttempted < 1); + expectedException.expectMessage(allOf( + containsString(dir.getPath()), + containsString("Tried 1 time.") + )); + remover.forceRemoveDirectoryContents(dir.toPath()); + assertFalse(d2.exists()); + assertFalse(f1.exists()); + assertFalse(d2f2.exists()); + } + + @Test + public void testForceRemoveRecursive() throws IOException { + File dir = tmp.newFolder(); + File d1 = new File(dir, "d1"); + File d2 = new File(dir, "d2"); + File f1 = new File(dir, "f1"); + File d1f1 = new File(d1, "d1f1"); + File d2f2 = new File(d2, "d1f2"); + mkdirs(d1, d2); + touchWithFileName(f1, d1f1, d2f2); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveRecursive(dir.toPath()); + + assertFalse(dir.exists()); + } + + @Test + public void testForceRemoveRecursive_DeletesAsMuchAsPossibleWithLockedFiles() throws Exception { + assumeTrue(Functions.isWindows()); + File dir = tmp.newFolder(); + File d1 = new File(dir, "d1"); + File d2 = new File(dir, "d2"); + File f1 = new File(dir, "f1"); + File d1f1 = new File(d1, "d1f1"); + File d2f2 = new File(d2, "d1f2"); + mkdirs(d1, d2); + touchWithFileName(f1, d1f1, d2f2); + + locker.acquireLock(d1f1); + PathRemover remover = PathRemover.newSimpleRemover(); + expectedException.expectMessage(containsString(dir.getPath())); + remover.forceRemoveRecursive(dir.toPath()); + assertTrue(dir.exists()); + assertTrue(d1.exists()); + assertTrue(d1f1.exists()); + assertFalse(d2.exists()); + assertFalse(d2f2.exists()); + assertFalse(f1.exists()); + } + + @Test + public void testForceRemoveRecursive_RetryOnFailure() throws Exception { + assumeTrue(Functions.isWindows()); + File dir = tmp.newFolder(); + File d1 = new File(dir, "d1"); + File d2 = new File(dir, "d2"); + File f1 = new File(dir, "f1"); + File d1f1 = new File(d1, "d1f1"); + File d2f2 = new File(d2, "d1f2"); + mkdirs(d1, d2); + touchWithFileName(f1, d1f1, d2f2); + locker.acquireLock(d2f2); + CountDownLatch unlockLatch = new CountDownLatch(1); + CountDownLatch deleteLatch = new CountDownLatch(1); + AtomicBoolean lockedFileExists = new AtomicBoolean(); + Thread thread = new Thread(() -> { + try { + unlockLatch.await(); + locker.releaseLock(d2f2); + deleteLatch.countDown(); + } catch (Exception ignored) { + } + }); + thread.start(); + PathRemover remover = PathRemover.newRemoverWithStrategy(retriesAttempted -> { + if (retriesAttempted == 0) { + lockedFileExists.set(d2f2.exists()); + unlockLatch.countDown(); + try { + deleteLatch.await(); + return true; + } catch (InterruptedException e) { + return false; + } + } + return false; + }); + remover.forceRemoveRecursive(dir.toPath()); + thread.join(); + assertTrue(lockedFileExists.get()); + assertFalse(dir.exists()); + } + + @Test + public void testForceRemoveRecursive_FailsWhenInterrupted() throws Exception { + assumeTrue(Functions.isWindows()); + File dir = tmp.newFolder(); + File d1 = new File(dir, "d1"); + File d2 = new File(dir, "d2"); + File f1 = new File(dir, "f1"); + File d1f1 = new File(d1, "d1f1"); + File d2f2 = new File(d2, "d1f2"); + mkdirs(d1, d2); + touchWithFileName(f1, d1f1, d2f2); + locker.acquireLock(d1f1); + AtomicReference interrupted = new AtomicReference<>(); + AtomicReference removed = new AtomicReference<>(); + PathRemover remover = PathRemover.newRemoverWithStrategy(retriesAttempted -> { + try { + TimeUnit.SECONDS.sleep(retriesAttempted + 1); + return true; + } catch (InterruptedException e) { + interrupted.set(e); + return false; + } + }); + Thread thread = new Thread(() -> { + try { + remover.forceRemoveRecursive(dir.toPath()); + } catch (IOException e) { + removed.set(e); + } + }); + thread.start(); + TimeUnit.MILLISECONDS.sleep(100); + thread.interrupt(); + thread.join(); + assertFalse(thread.isAlive()); + assertTrue(d1f1.exists()); + IOException ioException = removed.get(); + assertNotNull(ioException); + assertThat(ioException.getMessage(), containsString(dir.getPath())); + assertNotNull(interrupted.get()); + } + + @Test + public void testForceRemoveRecursive_ContainsSymbolicLinks() throws IOException { + File folder = tmp.newFolder(); + File d1 = new File(folder, "d1"); + File d1f1 = new File(d1, "d1f1"); + File f2 = new File(folder, "f2"); + mkdirs(d1); + touchWithFileName(d1f1, f2); + Path path = tmp.newFolder().toPath(); + Files.createSymbolicLink(path.resolve("sym-dir"), d1.toPath()); + Files.createSymbolicLink(path.resolve("sym-file"), f2.toPath()); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveRecursive(path); + + assertTrue("Unable to delete directory: " + path, Files.notExists(path)); + for (File file : Arrays.asList(d1, d1f1, f2)) { + assertTrue("Should not have deleted target: " + file, file.exists()); + } + } + + @Test + @Issue("JENKINS-55448") + public void testForceRemoveRecursive_ContainsDotPath() throws IOException { + File folder = tmp.newFolder(); + File d1 = new File(folder, "d1"); + File d1f1 = new File(d1, "d1f1"); + File f2 = new File(folder, "f2"); + mkdirs(d1); + touchWithFileName(d1f1, f2); + Path path = Paths.get(d1.getPath(), "..", "d1"); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveRecursive(path); + + assertTrue("Unable to delete directory: " + folder, Files.notExists(path)); + } + + @Test + @Issue("JENKINS-55448") + public void testForceRemoveRecursive_ParentIsSymbolicLink() throws IOException { + File folder = tmp.newFolder(); + File d1 = new File(folder, "d1"); + File d1f1 = new File(d1, "d1f1"); + File f2 = new File(folder, "f2"); + mkdirs(d1); + touchWithFileName(d1f1, f2); + Path symlink = Files.createSymbolicLink(tmp.getRoot().toPath().resolve("linked"), folder.toPath()); + Path d1p = symlink.resolve("d1"); + + PathRemover remover = PathRemover.newSimpleRemover(); + remover.forceRemoveRecursive(d1p); + + assertTrue("Unable to delete directory: " + d1p, Files.notExists(d1p)); + assertFalse(d1.exists()); + } + + private static void mkdirs(File... dirs) { + for (File dir : dirs) { + assertTrue("Could not mkdir " + dir, dir.mkdir()); + assertTrue(dir.isDirectory()); + } + } + + private static void touchWithFileName(File... files) throws IOException { + for (File file : files) { + try (FileWriter writer = new FileWriter(file)) { + writer.append(file.getName()).append(System.lineSeparator()); + } + assertTrue(file.isFile()); + } + } + +} \ No newline at end of file