diff --git a/pom.xml b/pom.xml index aa6bda3..d1717a4 100644 --- a/pom.xml +++ b/pom.xml @@ -99,11 +99,6 @@ under the License. provided - - org.codehaus.plexus - plexus-utils - - org.apache.maven.plugin-testing diff --git a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java index 67d3fe7..7f60747 100644 --- a/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java +++ b/src/main/java/org/apache/maven/plugins/clean/CleanMojo.java @@ -222,7 +222,7 @@ public class CleanMojo implements org.apache.maven.api.plugin.Mojo { @Override public void execute() { if (skip) { - getLog().info("Clean is skipped."); + logger.info("Clean is skipped."); return; } @@ -236,7 +236,7 @@ public void execute() { } else { fastDir = null; if (fast) { - getLog().warn("Fast clean requires maven 3.3.1 or newer, " + logger.warn("Fast clean requires maven 3.3.1 or newer, " + "or an explicit directory to be specified with the 'fastDir' configuration of " + "this plugin, or the 'maven.clean.fastDir' user property to be set."); } @@ -248,37 +248,22 @@ public void execute() { throw new IllegalArgumentException("Illegal value '" + fastMode + "' for fastMode. Allowed values are '" + FAST_MODE_BACKGROUND + "', '" + FAST_MODE_AT_END + "' and '" + FAST_MODE_DEFER + "'."); } - - Cleaner cleaner = new Cleaner(session, getLog(), isVerbose(), fastDir, fastMode); - + final var cleaner = + new Cleaner(session, logger, isVerbose(), fastDir, fastMode, followSymLinks, failOnError, retryOnError); try { for (Path directoryItem : getDirectories()) { if (directoryItem != null) { - cleaner.delete(directoryItem, null, followSymLinks, failOnError, retryOnError); + cleaner.delete(directoryItem); } } - if (filesets != null) { for (Fileset fileset : filesets) { if (fileset.getDirectory() == null) { throw new MojoException("Missing base directory for " + fileset); } - final String[] includes = fileset.getIncludes(); - final String[] excludes = fileset.getExcludes(); - final boolean useDefaultExcludes = fileset.isUseDefaultExcludes(); - final GlobSelector selector; - if ((includes != null && includes.length != 0) - || (excludes != null && excludes.length != 0) - || useDefaultExcludes) { - selector = new GlobSelector(includes, excludes, useDefaultExcludes); - } else { - selector = null; - } - cleaner.delete( - fileset.getDirectory(), selector, fileset.isFollowSymlinks(), failOnError, retryOnError); + cleaner.delete(fileset); } } - } catch (IOException e) { throw new MojoException("Failed to clean project: " + e.getMessage(), e); } @@ -290,7 +275,7 @@ public void execute() { * @return true if verbose output is enabled, false otherwise. */ private boolean isVerbose() { - return (verbose != null) ? verbose : getLog().isDebugEnabled(); + return (verbose != null) ? verbose : logger.isDebugEnabled(); } /** @@ -307,8 +292,4 @@ private Path[] getDirectories() { } return directories; } - - private Log getLog() { - return logger; - } } diff --git a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java index f19a9b5..0a399ac 100644 --- a/src/main/java/org/apache/maven/plugins/clean/Cleaner.java +++ b/src/main/java/org/apache/maven/plugins/clean/Cleaner.java @@ -20,15 +20,17 @@ import java.io.File; import java.io.IOException; +import java.nio.file.FileVisitOption; +import java.nio.file.FileVisitResult; +import java.nio.file.FileVisitor; import java.nio.file.Files; -import java.nio.file.LinkOption; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayDeque; +import java.util.BitSet; import java.util.Deque; -import java.util.function.BiConsumer; -import java.util.function.Consumer; +import java.util.EnumSet; import java.util.stream.Stream; import org.apache.maven.api.Event; @@ -36,121 +38,175 @@ import org.apache.maven.api.Listener; import org.apache.maven.api.Session; import org.apache.maven.api.SessionData; +import org.apache.maven.api.annotations.Nonnull; +import org.apache.maven.api.annotations.Nullable; import org.apache.maven.api.plugin.Log; -import org.codehaus.plexus.util.Os; - -import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_BACKGROUND; -import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_DEFER; /** * Cleans directories. * * @author Benjamin Bentmann + * @author Martin Desruisseaux */ -class Cleaner { - - private static final boolean ON_WINDOWS = Os.isFamily(Os.FAMILY_WINDOWS); +final class Cleaner implements FileVisitor { + /** + * Whether the host operating system is from the Windows family. + */ + private static final boolean ON_WINDOWS = (File.separatorChar == '\\'); private static final SessionData.Key LAST_DIRECTORY_TO_DELETE = SessionData.key(Path.class, Cleaner.class.getName() + ".lastDirectoryToDelete"); /** - * The maven session. This is typically non-null in a real run, but it can be during unit tests. + * The maven session. This is typically non-null in a real run, but it can be during unit tests. */ + @Nonnull private final Session session; - private final Logger logDebug; - - private final Logger logInfo; + /** + * The logger where to send information or warning messages. + */ + @Nonnull + private final Log logger; - private final Logger logVerbose; + /** + * Whether to send to the logger some information that would normally be at the "debug" level. + * Those information include the files or directories that are deleted. + */ + private final boolean verbose; - private final Logger logWarn; + /** + * Whether to list the files or directories that are deleted. This is a combination of {@link #verbose} + * with {@link #logger} configuration, and is stored because frequently requested. + */ + private final boolean listDeletedFiles; + @Nonnull private final Path fastDir; + @Nonnull private final String fastMode; + @Nullable + private Selector selector; + + /** + * Whether the base directory is excluded from the set of directories to delete. + * This is usually {@code false}, unless explicitly excluded by specifying an + * empty string in the excludes. + */ + private boolean isBaseDirectoryExcluded; + + private boolean followSymlinks; + + private final boolean failOnError; + + private final boolean retryOnError; + + /** + * Number of files that we failed to delete. + * This is incremented only if {@link #failOnError} is {@code false}, otherwise exceptions are thrown. + */ + private int failureCount; + + /** + * Whether each directory level contains at least one excluded file. + * This is used for determining whether the directory can be deleted. + * A bit is used for each directory level, with {@link #currentDepth} + * telling which bit is for the current directory. + */ + private final BitSet nonEmptyDirectoryLevels; + + /** + * 0 for the base directory, and incremented for each subdirectory. + */ + private int currentDepth; + /** * Creates a new cleaner. * * @param session the Maven session to be used - * @param log the logger to use, may be null to disable logging + * @param logger the logger to use * @param verbose whether to perform verbose logging * @param fastDir the explicit configured directory or to be deleted in fast mode * @param fastMode the fast deletion mode + * @param followSymlinks whether to follow symlinks + * @param failOnError whether to abort with an exception in case a selected file/directory could not be deleted + * @param retryOnError whether to undertake additional delete attempts in case the first attempt failed */ - Cleaner(Session session, Log log, boolean verbose, Path fastDir, String fastMode) { - logDebug = (log == null || !log.isDebugEnabled()) ? null : logger(log::debug, log::debug); - - logInfo = (log == null || !log.isInfoEnabled()) ? null : logger(log::info, log::info); - - logWarn = (log == null || !log.isWarnEnabled()) ? null : logger(log::warn, log::warn); - - logVerbose = verbose ? logInfo : logDebug; - + @SuppressWarnings("checkstyle:ParameterNumber") + Cleaner( + @Nonnull Session session, + @Nonnull Log logger, + boolean verbose, + @Nonnull Path fastDir, + @Nonnull String fastMode, + boolean followSymlinks, + boolean failOnError, + boolean retryOnError) { this.session = session; + this.logger = logger; + this.verbose = verbose; this.fastDir = fastDir; this.fastMode = fastMode; + this.followSymlinks = followSymlinks; + this.failOnError = failOnError; + this.retryOnError = retryOnError; + listDeletedFiles = verbose ? logger.isInfoEnabled() : logger.isDebugEnabled(); + nonEmptyDirectoryLevels = new BitSet(); } - private Logger logger(Consumer l1, BiConsumer l2) { - return new Logger() { - @Override - public void log(CharSequence message) { - l1.accept(message); - } - - @Override - public void log(CharSequence message, Throwable t) { - l2.accept(message, t); - } - }; + /** + * Deletes the specified fileset. + * + * @param fileset the fileset to delete, must not be {@code null} + * @throws IOException if a file/directory could not be deleted and {@code failOnError} is {@code true} + */ + public void delete(@Nonnull Fileset fileset) throws IOException { + selector = new Selector(fileset); + if (selector.isEmpty()) { + selector = null; + } + isBaseDirectoryExcluded = fileset.isBaseDirectoryExcluded(); + followSymlinks = fileset.isFollowSymlinks(); + delete(fileset.getDirectory()); } /** - * Deletes the specified directories and its contents. + * Deletes the specified directory and its contents. + * Non-existing directories will be silently ignored. * - * @param basedir the directory to delete, must not be null. Non-existing directories will be silently - * ignored - * @param selector the selector used to determine what contents to delete, may be null to delete - * everything - * @param followSymlinks whether to follow symlinks - * @param failOnError whether to abort with an exception in case a selected file/directory could not be deleted - * @param retryOnError whether to undertake additional delete attempts in case the first attempt failed - * @throws IOException if a file/directory could not be deleted and failOnError is true + * @param basedir the directory to delete, must not be {@code null} + * @throws IOException if a file/directory could not be deleted and {@code failOnError} is {@code true} */ - public void delete( - Path basedir, Selector selector, boolean followSymlinks, boolean failOnError, boolean retryOnError) - throws IOException { + public void delete(@Nonnull Path basedir) throws IOException { if (!Files.isDirectory(basedir)) { - if (!Files.exists(basedir)) { - if (logDebug != null) { - logDebug.log("Skipping non-existing directory " + basedir); + if (Files.notExists(basedir)) { + if (logger.isDebugEnabled()) { + logger.debug("Skipping non-existing directory " + basedir); } return; } throw new IOException("Invalid base directory " + basedir); } - - if (logInfo != null) { - logInfo.log("Deleting " + basedir + (selector != null ? " (" + selector + ")" : "")); + if (logger.isInfoEnabled()) { + logger.info("Deleting " + basedir + (selector != null ? " (" + selector + ")" : "")); + } + var options = EnumSet.noneOf(FileVisitOption.class); + if (followSymlinks) { + options.add(FileVisitOption.FOLLOW_LINKS); + basedir = getCanonicalPath(basedir, null); } - - Path file = followSymlinks ? basedir : getCanonicalPath(basedir); - if (selector == null && !followSymlinks && fastDir != null && session != null) { // If anything wrong happens, we'll just use the usual deletion mechanism - if (fastDelete(file)) { + if (fastDelete(basedir)) { return; } } - - delete(file, "", selector, followSymlinks, failOnError, retryOnError); + Files.walkFileTree(basedir, options, Integer.MAX_VALUE, this); } private boolean fastDelete(Path baseDir) { - Path fastDir = this.fastDir; // Handle the case where we use ${maven.multiModuleProjectDirectory}/target/.clean for example if (fastDir.toAbsolutePath().startsWith(baseDir.toAbsolutePath())) { try { @@ -167,9 +223,7 @@ private boolean fastDelete(Path baseDir) { throw e; } } catch (IOException e) { - if (logDebug != null) { - logDebug.log("Unable to fast delete directory", e); - } + logger.debug("Unable to fast delete directory", e); return false; } } @@ -179,15 +233,12 @@ private boolean fastDelete(Path baseDir) { Files.createDirectories(fastDir); } } catch (IOException e) { - if (logDebug != null) { - logDebug.log( - "Unable to fast delete directory as the path " + fastDir - + " does not point to a directory or cannot be created", - e); - } + logger.debug( + "Unable to fast delete directory as the path " + fastDir + + " does not point to a directory or cannot be created", + e); return false; } - try { Path tmpDir = Files.createTempDirectory(fastDir, ""); Path dstDir = tmpDir.resolve(baseDir.getFileName()); @@ -199,175 +250,198 @@ private boolean fastDelete(Path baseDir) { BackgroundCleaner.delete(this, tmpDir, fastMode); return true; } catch (IOException e) { - if (logDebug != null) { - logDebug.log("Unable to fast delete directory", e); - } + logger.debug("Unable to fast delete directory", e); return false; } } /** - * Deletes the specified file or directory. - * - * @param file the file/directory to delete, must not be null. If followSymlinks is - * false, it is assumed that the parent file is canonical - * @param pathname the relative pathname of the file, using {@link File#separatorChar}, must not be - * null - * @param selector the selector used to determine what contents to delete, may be null to delete - * everything - * @param followSymlinks whether to follow symlinks - * @param failOnError whether to abort with an exception in case a selected file/directory could not be deleted - * @param retryOnError whether to undertake additional delete attempts in case the first attempt failed - * @return The result of the cleaning, never null - * @throws IOException if a file/directory could not be deleted and failOnError is true + * Invoked for a directory before entries in the directory are visited. + * Determines if the given directory should be scanned for files to delete. */ - private Result delete( - Path file, - String pathname, - Selector selector, - boolean followSymlinks, - boolean failOnError, - boolean retryOnError) - throws IOException { - Result result = new Result(); - - boolean isDirectory = Files.isDirectory(file); - - if (isDirectory) { - if (selector == null || selector.couldHoldSelected(pathname)) { - final boolean isSymlink = isSymbolicLink(file); - Path canonical = followSymlinks ? file : getCanonicalPath(file); - if (followSymlinks || !isSymlink) { - String prefix = !pathname.isEmpty() ? pathname + File.separatorChar : ""; - try (Stream children = Files.list(canonical)) { - for (Path child : children.toList()) { - result.update(delete( - child, - prefix + child.getFileName(), - selector, - followSymlinks, - failOnError, - retryOnError)); - } - } - } else if (logDebug != null) { - logDebug.log("Not recursing into symlink " + file); - } - } else if (logDebug != null) { - logDebug.log("Not recursing into directory without included files " + file); - } + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException { + if (ON_WINDOWS && !followSymlinks && attrs.isOther()) { + /* + * MCLEAN-93: NTFS junctions have isDirectory() and isOther() attributes set. + * If not following symbolic links, then it should be handled as a file. + */ + visitFile(dir, attrs); + return FileVisitResult.SKIP_SUBTREE; + } + if (selector == null || selector.couldHoldSelected(dir)) { + nonEmptyDirectoryLevels.clear(++currentDepth); + return FileVisitResult.CONTINUE; + } else { + nonEmptyDirectoryLevels.set(currentDepth); // Remember that this directory is not empty. + logger.debug("Not recursing into directory without included files " + dir); + return FileVisitResult.SKIP_SUBTREE; } + } - if (!result.excluded && (selector == null || selector.isSelected(pathname))) { - if (logVerbose != null) { - if (isDirectory) { - logVerbose.log("Deleting directory " + file); - } else if (Files.exists(file)) { - logVerbose.log("Deleting file " + file); - } else { - logVerbose.log("Deleting dangling symlink " + file); - } + /** + * Invoked for a file in a directory. + * Deletes that file, unless the file is excluded by the selector. + */ + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException { + if ((selector == null || selector.matches(file)) && tryDelete(file)) { + if (listDeletedFiles) { + logDelete(file, attrs); } - result.failures += delete(file, failOnError, retryOnError); } else { - result.excluded = true; + nonEmptyDirectoryLevels.set(currentDepth); // Remember that the directory will not be empty. } + return FileVisitResult.CONTINUE; + } - return result; + /** + * Invoked for a file that could not be visited. + */ + @Override + public FileVisitResult visitFileFailed(Path file, IOException failure) throws IOException { + if (logger.isWarnEnabled()) { + logger.warn("Failed to visit " + file, failure); + } + failureCount++; + nonEmptyDirectoryLevels.set(currentDepth); // Remember that the directory will not be empty. + if (failOnError) { + throw failure; + } + return FileVisitResult.CONTINUE; } - private static Path getCanonicalPath(Path path) { + /** + * Invoked for a directory after all files and sub-trees have been visited. + * If the directory is not empty, then this method deletes it. + */ + @Override + public FileVisitResult postVisitDirectory(Path dir, IOException failure) throws IOException { + boolean canDelete = !nonEmptyDirectoryLevels.get(currentDepth--); // False if the directory is not empty. + if (failure != null) { + if (logger.isWarnEnabled()) { + logger.warn("Error in directory " + dir, failure); + } + failureCount++; + canDelete = false; + } else { + canDelete &= (currentDepth != 0 || !isBaseDirectoryExcluded); + if (canDelete && selector != null) { + canDelete = selector.matches(dir); + } + } + if (canDelete) { + if (tryDelete(dir) && listDeletedFiles) { + logDelete(dir, null); + } + } else { + nonEmptyDirectoryLevels.set(currentDepth); // Remember that the parent of `dir` is not empty. + } + if (failure != null && failOnError) { + throw failure; + } + return FileVisitResult.CONTINUE; + } + + /** + * Returns the real path of the given file. If the real path cannot be obtained, + * this method tries to get the real path of the parent and to append the rest of + * the filename. + * + * @param path the path to get as a canonical path + * @param mainError should be {@code null} (reserved to recursive calls of this method) + * @return the real path of the given path + * @throws IOException if the canonical path cannot be obtained + */ + private static Path getCanonicalPath(final Path path, IOException mainError) throws IOException { try { return path.toRealPath(); } catch (IOException e) { - return getCanonicalPath(path.getParent()).resolve(path.getFileName()); + if (mainError == null) { + mainError = e; + } else { + mainError.addSuppressed(e); + } + final Path parent = path.getParent(); + if (parent != null) { + return getCanonicalPath(parent, mainError).resolve(path.getFileName()); + } + throw e; } } - private boolean isSymbolicLink(Path path) throws IOException { - BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS); - return attrs.isSymbolicLink() - // MCLEAN-93: NTFS junctions have isDirectory() and isOther() attributes set - || (attrs.isDirectory() && attrs.isOther()); - } - /** - * Deletes the specified file or directory. If the path denotes a symlink, only the link is removed. Its target is - * left untouched. + * Deletes the specified file or directory. + * If the path denotes a symlink, only the link is removed. Its target is left untouched. + * This method returns {@code true} if the file has been deleted, or {@code false} if the + * file does not exist or if an {@link IOException} occurred but {@link #failOnError} is + * {@code false}. * - * @param file the file/directory to delete, must not be null - * @param failOnError whether to abort with an exception if the file/directory could not be deleted - * @param retryOnError whether to undertake additional delete attempts if the first attempt failed - * @return 0 if the file was deleted, 1 otherwise - * @throws IOException if a file/directory could not be deleted and failOnError is true + * @param file the file/directory to delete, must not be {@code null} + * @return whether the file has been deleted + * @throws IOException if a file/directory could not be deleted and {@code failOnError} is {@code true} */ - private int delete(Path file, boolean failOnError, boolean retryOnError) throws IOException { - IOException failure = delete(file); - if (failure != null) { - + @SuppressWarnings("SleepWhileInLoop") + private boolean tryDelete(final Path file) throws IOException { + try { + return Files.deleteIfExists(file); + } catch (IOException failure) { if (retryOnError) { if (ON_WINDOWS) { // try to release any locks held by non-closed files System.gc(); } - - final int[] delays = {50, 250, 750}; - for (int delay : delays) { + for (int delay : new int[] {50, 250, 750}) { try { Thread.sleep(delay); } catch (InterruptedException e) { - throw new IOException(e); + failure.addSuppressed(e); + throw failure; } - failure = delete(file); - if (failure == null) { - break; + try { + return Files.deleteIfExists(file); + } catch (IOException again) { + again.addSuppressed(failure); + failure = again; } } } - - if (Files.exists(file)) { - if (failOnError) { - throw new IOException("Failed to delete " + file, failure); - } else { - if (logWarn != null) { - logWarn.log("Failed to delete " + file, failure); - } - return 1; - } + if (logger.isWarnEnabled()) { + logger.warn("Failed to delete " + file, failure); } + failureCount++; + if (failOnError) { + throw failure; + } + return false; } - - return 0; } - private static IOException delete(Path file) { - try { - Files.deleteIfExists(file); - } catch (IOException e) { - return e; + /** + * Reports that a file, directory or symbolic link has been deleted. This method should be invoked only + * when {@link #tryDelete(Path)} returned {@code true} and {@link #listDeletedFiles} is {@code true}. + * + *

If {@code attrs} is {@code null}, then the file is assumed a directory. This arbitrary rule + * is an implementation convenience specific to the context in which we invoke this method.

+ */ + private void logDelete(final Path file, final BasicFileAttributes attrs) { + String message; + if (attrs == null || attrs.isDirectory()) { + message = "Deleted directory " + file; + } else if (attrs.isRegularFile()) { + message = "Deleted file " + file; + } else if (attrs.isSymbolicLink()) { + message = "Deleted dangling symlink " + file; + } else { + message = "Deleted " + file; } - return null; - } - - private static class Result { - - private int failures; - - private boolean excluded; - - public void update(Result result) { - failures += result.failures; - excluded |= result.excluded; + if (verbose) { + logger.info(message); + } else { + logger.debug(message); } } - private interface Logger { - - void log(CharSequence message); - - void log(CharSequence message, Throwable t); - } - private static class BackgroundCleaner extends Thread { private static BackgroundCleaner instance; @@ -409,13 +483,14 @@ private BackgroundCleaner(Cleaner cleaner, Path dir, String fastMode) { @Override public void run() { - while (true) { - Path basedir = pollNext(); - if (basedir == null) { - break; - } + var options = EnumSet.noneOf(FileVisitOption.class); + if (cleaner.followSymlinks) { + options.add(FileVisitOption.FOLLOW_LINKS); + } + Path basedir; + while ((basedir = pollNext()) != null) { try { - cleaner.delete(basedir, "", null, false, false, true); + Files.walkFileTree(basedir, options, Integer.MAX_VALUE, cleaner); } catch (IOException e) { // do not display errors } @@ -457,7 +532,7 @@ synchronized boolean doDelete(Path dir) { return false; } filesToDelete.add(dir); - if (status == NEW && FAST_MODE_BACKGROUND.equals(fastMode)) { + if (status == NEW && CleanMojo.FAST_MODE_BACKGROUND.equals(fastMode)) { status = RUNNING; notifyAll(); start(); @@ -486,11 +561,9 @@ synchronized void doSessionEnd() { if (status == NEW) { start(); } - if (!FAST_MODE_DEFER.equals(fastMode)) { + if (!CleanMojo.FAST_MODE_DEFER.equals(fastMode)) { try { - if (cleaner.logInfo != null) { - cleaner.logInfo.log("Waiting for background file deletion"); - } + cleaner.logger.info("Waiting for background file deletion"); while (status != STOPPED) { wait(); } diff --git a/src/main/java/org/apache/maven/plugins/clean/Fileset.java b/src/main/java/org/apache/maven/plugins/clean/Fileset.java index 90d15c5..2842ec6 100644 --- a/src/main/java/org/apache/maven/plugins/clean/Fileset.java +++ b/src/main/java/org/apache/maven/plugins/clean/Fileset.java @@ -19,11 +19,10 @@ package org.apache.maven.plugins.clean; import java.nio.file.Path; -import java.util.Arrays; /** * Customizes the string representation of - * org.apache.maven.shared.model.fileset.FileSet to return the + * {@code org.apache.maven.shared.model.fileset.FileSet} to return the * included and excluded files from the file-set's directory. Specifically, * "file-set: [directory] (included: [included files], * excluded: [excluded files])" @@ -43,53 +42,87 @@ public class Fileset { private boolean useDefaultExcludes; /** - * @return {@link #directory} + * {@return the base directory}. */ public Path getDirectory() { return directory; } /** - * @return {@link #includes} + * {@return the patterns of the file to include, or an empty array if unspecified}. */ public String[] getIncludes() { return (includes != null) ? includes : new String[0]; } /** - * @return {@link #excludes} + * {@return the patterns of the file to exclude, or an empty array if unspecified}. */ public String[] getExcludes() { return (excludes != null) ? excludes : new String[0]; } /** - * @return {@link #followSymlinks} + * {@return whether the base directory is excluded from the fileset}. + * This is {@code false} by default. The base directory can be excluded + * explicitly if the exclude patterns contains an empty string. + */ + public boolean isBaseDirectoryExcluded() { + if (excludes != null) { + for (String pattern : excludes) { + if (pattern == null || pattern.isEmpty()) { + return true; + } + } + } + return false; + } + + /** + * {@return whether to follow symbolic links}. */ public boolean isFollowSymlinks() { return followSymlinks; } /** - * @return {@link #useDefaultExcludes} + * {@return whether to use a default set of excludes}. */ public boolean isUseDefaultExcludes() { return useDefaultExcludes; } /** - * Retrieves the included and excluded files from this file-set's directory. - * Specifically, "file-set: [directory] (included: - * [included files], excluded: [excluded files])" + * Appends the elements of the given array in the given buffer. + * This is a helper method for {@link #toString()} implementations. * - * @return The included and excluded files from this file-set's directory. + * @param buffer the buffer where to add the elements + * @param label label identifying the array of elements to add + * @param patterns the elements to append, or {@code null} if none + */ + static void append(StringBuilder buffer, String label, String[] patterns) { + buffer.append(label).append(": ["); + if (patterns != null) { + for (int i = 0; i < patterns.length; i++) { + if (i != 0) { + buffer.append(", "); + } + buffer.append(patterns[i]); + } + } + buffer.append(']'); + } + + /** + * {@return a string representation of the included and excluded files from this file-set's directory}. * Specifically, "file-set: [directory] (included: * [included files], excluded: [excluded files])" - * @see java.lang.Object#toString() */ @Override public String toString() { - return "file set: " + getDirectory() + " (included: " + Arrays.asList(getIncludes()) + ", excluded: " - + Arrays.asList(getExcludes()) + ")"; + var buffer = new StringBuilder("file set: ").append(getDirectory()); + append(buffer.append(" ("), "included", getIncludes()); + append(buffer.append(", "), "excluded", getExcludes()); + return buffer.append(')').toString(); } } diff --git a/src/main/java/org/apache/maven/plugins/clean/GlobSelector.java b/src/main/java/org/apache/maven/plugins/clean/GlobSelector.java deleted file mode 100644 index 3d61071..0000000 --- a/src/main/java/org/apache/maven/plugins/clean/GlobSelector.java +++ /dev/null @@ -1,122 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you 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 org.apache.maven.plugins.clean; - -import java.io.File; -import java.util.Arrays; - -import org.codehaus.plexus.util.DirectoryScanner; -import org.codehaus.plexus.util.SelectorUtils; - -/** - * Selects paths based on Ant-like glob patterns. - * - * @author Benjamin Bentmann - */ -class GlobSelector implements Selector { - - private final String[] includes; - - private final String[] excludes; - - private final String str; - - GlobSelector(String[] includes, String[] excludes, boolean useDefaultExcludes) { - this.str = "includes = " + toString(includes) + ", excludes = " + toString(excludes); - this.includes = normalizePatterns(includes); - this.excludes = normalizePatterns(addDefaultExcludes(excludes, useDefaultExcludes)); - } - - private static String toString(String[] patterns) { - return (patterns == null) ? "[]" : Arrays.asList(patterns).toString(); - } - - private static String[] addDefaultExcludes(String[] excludes, boolean useDefaultExcludes) { - String[] defaults = DirectoryScanner.DEFAULTEXCLUDES; - if (!useDefaultExcludes) { - return excludes; - } else if (excludes == null || excludes.length <= 0) { - return defaults; - } else { - String[] patterns = new String[excludes.length + defaults.length]; - System.arraycopy(excludes, 0, patterns, 0, excludes.length); - System.arraycopy(defaults, 0, patterns, excludes.length, defaults.length); - return patterns; - } - } - - private static String[] normalizePatterns(String[] patterns) { - String[] normalized; - - if (patterns != null) { - normalized = new String[patterns.length]; - for (int i = patterns.length - 1; i >= 0; i--) { - normalized[i] = normalizePattern(patterns[i]); - } - } else { - normalized = new String[0]; - } - - return normalized; - } - - private static String normalizePattern(String pattern) { - if (pattern == null) { - return ""; - } - - String normalized = pattern.replace((File.separatorChar == '/') ? '\\' : '/', File.separatorChar); - - if (normalized.endsWith(File.separator)) { - normalized += "**"; - } - - return normalized; - } - - @Override - public boolean isSelected(String pathname) { - return (includes.length <= 0 || isMatched(pathname, includes)) - && (excludes.length <= 0 || !isMatched(pathname, excludes)); - } - - private static boolean isMatched(String pathname, String[] patterns) { - for (String pattern : patterns) { - if (SelectorUtils.matchPath(pattern, pathname)) { - return true; - } - } - return false; - } - - @Override - public boolean couldHoldSelected(String pathname) { - for (String include : includes) { - if (SelectorUtils.matchPatternStart(include, pathname)) { - return true; - } - } - return includes.length <= 0; - } - - @Override - public String toString() { - return str; - } -} diff --git a/src/main/java/org/apache/maven/plugins/clean/Selector.java b/src/main/java/org/apache/maven/plugins/clean/Selector.java index 93a0695..ee84048 100644 --- a/src/main/java/org/apache/maven/plugins/clean/Selector.java +++ b/src/main/java/org/apache/maven/plugins/clean/Selector.java @@ -18,28 +18,405 @@ */ package org.apache.maven.plugins.clean; +import java.io.File; +import java.nio.file.FileSystem; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.util.LinkedHashSet; +import java.util.Set; + /** - * Determines whether a path is selected for deletion. The pathnames used for method parameters will be relative to some - * base directory and use {@link java.io.File#separatorChar} as separator. + * Determines whether a path is selected for deletion. + * The pathnames used for method parameters will be relative to some base directory + * and use {@code '/'} as separator, regardless the hosting operating system. + * + *

Syntax

+ * If a pattern contains the {@code ':'} character, then that pattern is given verbatim to + * {@link FileSystem#getPathMatcher(String)}, which will interpret the part before {@code ':'} + * as the syntax (usually {@code "glob"} or {@code "regex"}). If a pattern does not contain the + * {@code ':'} character, then the syntax defaults to a reproduction of the Maven 3 behavior. + * This is implemented as the {@code "glob"} syntax with the following modifications: + * + *
    + *
  • The platform-specific separator ({@code '\\'} on Windows) is replaced by {@code '/'}. + * Note that it means that the backslash cannot be used for escaping characters.
  • + *
  • Trailing {@code "/"} is completed as {@code "/**"}.
  • + *
  • The {@code "**"} wildcard means "0 or more directories" instead of "1 or more directories". + * This is implemented by adding variants of the pattern without the {@code "**"} wildcard.
  • + *
+ * + * If above changes are not desired, put an explicit {@code "glob:"} prefix before the patterns. + * Note that putting such prefix is recommended anyway for better performances. * * @author Benjamin Bentmann + * @author Martin Desruisseaux */ -interface Selector { +final class Selector implements PathMatcher { + /** + * Patterns which should be excluded by default, like SCM files. + * + *

Source: this list is copied from {@code plexus-utils-4.0.2} (released in + * September 23, 2024), class {@code org.codehaus.plexus.util.AbstractScanner}.

+ */ + private static final String[] DEFAULT_EXCLUDES = { + // Miscellaneous typical temporary files + "**/*~", + "**/#*#", + "**/.#*", + "**/%*%", + "**/._*", + + // CVS + "**/CVS", + "**/CVS/**", + "**/.cvsignore", + + // RCS + "**/RCS", + "**/RCS/**", + + // SCCS + "**/SCCS", + "**/SCCS/**", + + // Visual SourceSafe + "**/vssver.scc", + + // MKS + "**/project.pj", + + // Subversion + "**/.svn", + "**/.svn/**", + + // Arch + "**/.arch-ids", + "**/.arch-ids/**", + + // Bazaar + "**/.bzr", + "**/.bzr/**", + + // SurroundSCM + "**/.MySCMServerInfo", + + // Mac + "**/.DS_Store", + + // Serena Dimensions Version 10 + "**/.metadata", + "**/.metadata/**", + + // Mercurial + "**/.hg", + "**/.hg/**", + + // git + "**/.git", + "**/.git/**", + "**/.gitignore", + + // BitKeeper + "**/BitKeeper", + "**/BitKeeper/**", + "**/ChangeSet", + "**/ChangeSet/**", + + // darcs + "**/_darcs", + "**/_darcs/**", + "**/.darcsrepo", + "**/.darcsrepo/**", + "**/-darcs-backup*", + "**/.darcs-temp-mail" + }; + + /** + * String representation of the normalized include filters. + * This is kept only for {@link #toString()} implementation. + */ + private final String[] includePatterns; + + /** + * String representation of the normalized exclude filters. + * This is kept only for {@link #toString()} implementation. + */ + private final String[] excludePatterns; + + /** + * The matcher for includes. The length of this array is equal to {@link #includePatterns} array length. + */ + private final PathMatcher[] includes; + + /** + * The matcher for excludes. The length of this array is equal to {@link #excludePatterns} array length. + */ + private final PathMatcher[] excludes; + + /** + * The matcher for all directories to include. This array includes the parents of all those directories, + * because they need to be accepted before we can walk to the sub-directories. + * This is an optimization for skipping whole directories when possible. + */ + private final PathMatcher[] dirIncludes; + + /** + * The matcher for directories to exclude. This array does not include the parent directories, + * since they may contain other sub-trees that need to be included. + * This is an optimization for skipping whole directories when possible. + */ + private final PathMatcher[] dirExcludes; + + /** + * The base directory. All files will be relativized to that directory before to be matched. + */ + private final Path baseDirectory; + + /** + * Creates a new selector from the given file seT. + * + * @param fs the user-specified configuration + */ + Selector(Fileset fs) { + includePatterns = normalizePatterns(fs.getIncludes(), false); + excludePatterns = normalizePatterns(addDefaultExcludes(fs.getExcludes(), fs.isUseDefaultExcludes()), true); + baseDirectory = fs.getDirectory(); + FileSystem system = baseDirectory.getFileSystem(); + includes = matchers(system, includePatterns); + excludes = matchers(system, excludePatterns); + dirIncludes = matchers(system, directoryPatterns(includePatterns, false)); + dirExcludes = matchers(system, directoryPatterns(excludePatterns, true)); + } + + /** + * Returns the given array of excludes, optionally expanded with a default set of excludes. + * + * @param excludes the user-specified excludes. + * @param useDefaultExcludes whether to expand user exclude with the set of default excludes + * @return the potentially expanded set of excludes to use + */ + private static String[] addDefaultExcludes(final String[] excludes, final boolean useDefaultExcludes) { + if (!useDefaultExcludes) { + return excludes; + } + String[] defaults = DEFAULT_EXCLUDES; + if (excludes == null || excludes.length == 0) { + return defaults; + } else { + String[] patterns = new String[excludes.length + defaults.length]; + System.arraycopy(excludes, 0, patterns, 0, excludes.length); + System.arraycopy(defaults, 0, patterns, excludes.length, defaults.length); + return patterns; + } + } + + /** + * Returns the given array of patterns with path separator normalized to {@code '/'}. + * Null or empty patterns are ignored, and duplications are removed. + * + * @param patterns the patterns to normalize + * @param excludes whether the patterns are exclude patterns + * @return normalized patterns without null, empty or duplicated patterns + */ + private static String[] normalizePatterns(final String[] patterns, final boolean excludes) { + if (patterns == null) { + return new String[0]; + } + // TODO: use `LinkedHashSet.newLinkedHashSet(int)` instead with JDK19. + final var normalized = new LinkedHashSet(patterns.length); + for (String pattern : patterns) { + if (pattern != null && !pattern.isEmpty()) { + final boolean useMavenSyntax = pattern.indexOf(':') < 0; + if (useMavenSyntax) { + pattern = pattern.replace(File.separatorChar, '/'); + if (pattern.endsWith("/")) { + pattern += "**"; + } + // Following are okay only when "**" means "0 or more directories". + while (pattern.endsWith("/**/**")) { + pattern = pattern.substring(0, pattern.length() - 3); + } + while (pattern.startsWith("**/**/")) { + pattern = pattern.substring(3); + } + pattern = pattern.replace("/**/**/", "/**/"); + } + normalized.add(pattern); + /* + * If the pattern starts or ends with "**", Java GLOB expects a directory level at + * that location while Maven seems to consider that "**" can mean "no directory". + * Add another pattern for reproducing this effect. + */ + if (useMavenSyntax) { + addPatternsWithOneDirRemoved(normalized, pattern, 0); + } + } + } + return simplify(normalized, excludes); + } + + /** + * Adds all variants of the given pattern with {@code **} removed. + * This is used for simulating the Maven behavior where {@code "**} may match zero directory. + * Tests suggest that we need an explicit GLOB pattern with no {@code "**"} for matching an absence of directory. + * + * @param patterns where to add the derived patterns + * @param pattern the pattern for which to add derived forms + * @param end should be 0 (reserved for recursive invocations of this method) + */ + private static void addPatternsWithOneDirRemoved(final Set patterns, final String pattern, int end) { + final int length = pattern.length(); + int start; + while ((start = pattern.indexOf("**", end)) >= 0) { + end = start + 2; // 2 is the length of "**". + if (end < length) { + if (pattern.charAt(end) != '/') { + continue; + } + if (start == 0) { + end++; // Ommit the leading slash if there is nothing before it. + } + } + if (start > 0) { + if (pattern.charAt(--start) != '/') { + continue; + } + } + String reduced = pattern.substring(0, start) + pattern.substring(end); + patterns.add(reduced); + addPatternsWithOneDirRemoved(patterns, reduced, start); + } + } + + /** + * Applies some heuristic rules for simplifying the set of patterns, + * then returns the patterns as an array. + * + * @param patterns the patterns to simplify and return asarray + * @param excludes whether the patterns are exclude patterns + * @return the set content as an array, after simplification + */ + private static String[] simplify(Set patterns, boolean excludes) { + /* + * If the "**" pattern is present, it makes all other patterns useless. + * In the case of include patterns, an empty set means to include everything. + */ + if (patterns.remove("**")) { + patterns.clear(); + if (excludes) { + patterns.add("**"); + } + } + return patterns.toArray(String[]::new); + } + + /** + * Eventually adds the parent directory of the given patterns, without duplicated values. + * The patterns given to this method should have been normalized. + * + * @param patterns the normalized include or exclude patterns + * @param excludes whether the patterns are exclude patterns + * @return pattens of directories to include or exclude + */ + private static String[] directoryPatterns(final String[] patterns, final boolean excludes) { + // TODO: use `LinkedHashSet.newLinkedHashSet(int)` instead with JDK19. + final var directories = new LinkedHashSet(patterns.length); + for (String pattern : patterns) { + int s = pattern.indexOf(':'); + if (s < 0 || pattern.startsWith("glob:")) { + if (excludes) { + if (pattern.endsWith("/**")) { + directories.add(pattern.substring(0, pattern.length() - 3)); + } + } else { + if (pattern.regionMatches(++s, "**/", 0, 3)) { + s = pattern.indexOf('/', s + 3); + if (s < 0) { + return new String[0]; // Pattern is "**", so we need to accept everything. + } + directories.add(pattern.substring(0, s)); + } + } + } + } + return simplify(directories, excludes); + } + + /** + * Creates the path matchers for the given patterns. + * If no syntax is specified, the default is {@code glob}. + */ + private static PathMatcher[] matchers(final FileSystem fs, final String[] patterns) { + final var matchers = new PathMatcher[patterns.length]; + for (int i = 0; i < patterns.length; i++) { + String pattern = patterns[i]; + if (pattern.indexOf(':') < 0) { + pattern = "glob:" + pattern; + } + matchers[i] = fs.getPathMatcher(pattern); + } + return matchers; + } + + /** + * {@return whether there is no include or exclude filters}. + * In such case, this {@code Selector} instance should be ignored. + */ + boolean isEmpty() { + return (includes.length | excludes.length) == 0; + } /** * Determines whether a path is selected for deletion. + * This is true if the given file matches an include pattern and no exclude pattern. * - * @param pathname The pathname to test, must not be null. - * @return true if the given path is selected for deletion, false otherwise. + * @param pathname The pathname to test, must not be {@code null} + * @return {@code true} if the given path is selected for deletion, {@code false} otherwise */ - boolean isSelected(String pathname); + @Override + public boolean matches(Path path) { + path = baseDirectory.relativize(path); + return (includes.length == 0 || isMatched(path, includes)) + && (excludes.length == 0 || !isMatched(path, excludes)); + } + + /** + * {@return whether the given file matches according one of the given matchers}. + */ + private static boolean isMatched(Path path, PathMatcher[] matchers) { + for (PathMatcher matcher : matchers) { + if (matcher.matches(path)) { + return true; + } + } + return false; + } /** * Determines whether a directory could contain selected paths. * - * @param pathname The directory pathname to test, must not be null. - * @return true if the given directory might contain selected paths, false if the - * directory will definitively not contain selected paths.. + * @param directory the directory pathname to test, must not be {@code null} + * @return {@code true} if the given directory might contain selected paths, {@code false} if the + * directory will definitively not contain selected paths + */ + public boolean couldHoldSelected(Path directory) { + if (baseDirectory.equals(directory)) { + return true; + } + directory = baseDirectory.relativize(directory); + return (dirIncludes.length == 0 || isMatched(directory, dirIncludes)) + && (dirExcludes.length == 0 || !isMatched(directory, dirExcludes)); + } + + /** + * {@return a string representation for logging purposes}. + * This string representation is reported logged when {@link Cleaner} is executed. */ - boolean couldHoldSelected(String pathname); + @Override + public String toString() { + var buffer = new StringBuilder(); + Fileset.append(buffer, "includes", includePatterns); + Fileset.append(buffer.append(", "), "excludes", excludePatterns); + return buffer.toString(); + } } diff --git a/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java b/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java index 02e8bf6..670133c 100644 --- a/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java +++ b/src/test/java/org/apache/maven/plugins/clean/CleanMojoTest.java @@ -29,6 +29,7 @@ import java.nio.file.Paths; import java.util.Collections; +import org.apache.maven.api.plugin.Log; import org.apache.maven.api.plugin.MojoException; import org.apache.maven.api.plugin.testing.Basedir; import org.apache.maven.api.plugin.testing.InjectMojo; @@ -40,11 +41,11 @@ import static org.apache.maven.api.plugin.testing.MojoExtension.getBasedir; import static org.apache.maven.api.plugin.testing.MojoExtension.setVariableValueToObject; -import static org.codehaus.plexus.util.IOUtil.copy; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; /** * Test the clean mojo. @@ -52,6 +53,8 @@ @MojoTest public class CleanMojoTest { + private final Log log = mock(Log.class); + /** * Tests the simple removal of directories * @@ -211,8 +214,8 @@ public void testFollowLinksWithWindowsJunction() throws Exception { .start(); process.waitFor(); ByteArrayOutputStream baos = new ByteArrayOutputStream(); - copy(process.getInputStream(), baos); - copy(process.getErrorStream(), baos); + process.getInputStream().transferTo(baos); + process.getErrorStream().transferTo(baos); if (!Files.exists(link)) { throw new IOException("Unable to create junction: " + baos); } @@ -241,7 +244,7 @@ interface LinkCreator { } private void testSymlink(LinkCreator linkCreator) throws Exception { - Cleaner cleaner = new Cleaner(null, null, false, null, null); + Cleaner cleaner = new Cleaner(null, log, false, null, null, false, true, false); Path testDir = Paths.get("target/test-classes/unit/test-dir").toAbsolutePath(); Path dirWithLnk = testDir.resolve("dir"); Path orgDir = testDir.resolve("org-dir"); @@ -254,7 +257,7 @@ private void testSymlink(LinkCreator linkCreator) throws Exception { Files.write(file, Collections.singleton("Hello world")); linkCreator.createLink(jctDir, orgDir); // delete - cleaner.delete(dirWithLnk, null, false, true, false); + cleaner.delete(dirWithLnk); // verify assertTrue(Files.exists(file)); assertFalse(Files.exists(jctDir)); @@ -267,7 +270,8 @@ private void testSymlink(LinkCreator linkCreator) throws Exception { Files.write(file, Collections.singleton("Hello world")); linkCreator.createLink(jctDir, orgDir); // delete - cleaner.delete(dirWithLnk, null, true, true, false); + cleaner = new Cleaner(null, log, false, null, null, true, true, false); + cleaner.delete(dirWithLnk); // verify assertFalse(Files.exists(file)); assertFalse(Files.exists(jctDir)); diff --git a/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java b/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java index 798ee3a..c4e8567 100644 --- a/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java +++ b/src/test/java/org/apache/maven/plugins/clean/CleanerTest.java @@ -18,9 +18,7 @@ */ package org.apache.maven.plugins.clean; -import java.io.IOException; import java.nio.file.AccessDeniedException; -import java.nio.file.DirectoryNotEmptyException; import java.nio.file.Path; import java.nio.file.attribute.PosixFilePermission; import java.nio.file.attribute.PosixFilePermissions; @@ -41,8 +39,8 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; @@ -61,8 +59,8 @@ class CleanerTest { void deleteSucceedsDeeply(@TempDir Path tempDir) throws Exception { final Path basedir = createDirectory(tempDir.resolve("target")).toRealPath(); final Path file = createFile(basedir.resolve("file")); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); - cleaner.delete(basedir, null, false, true, false); + final var cleaner = new Cleaner(null, log, false, null, null, false, true, false); + cleaner.delete(basedir); assertFalse(exists(basedir)); assertFalse(exists(file)); } @@ -76,14 +74,10 @@ void deleteFailsWithoutRetryWhenNoPermission(@TempDir Path tempDir) throws Excep // Remove the executable flag to prevent directory listing, which will result in a DirectoryNotEmptyException. final Set permissions = PosixFilePermissions.fromString("rw-rw-r--"); setPosixFilePermissions(basedir, permissions); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); - final IOException exception = - assertThrows(IOException.class, () -> cleaner.delete(basedir, null, false, true, false)); - verify(log, never()).warn(any(CharSequence.class), any(Throwable.class)); - assertEquals("Failed to delete " + basedir, exception.getMessage()); - final DirectoryNotEmptyException cause = - assertInstanceOf(DirectoryNotEmptyException.class, exception.getCause()); - assertEquals(basedir.toString(), cause.getMessage()); + final var cleaner = new Cleaner(null, log, false, null, null, false, true, false); + final var exception = assertThrows(AccessDeniedException.class, () -> cleaner.delete(basedir)); + verify(log, times(1)).warn(any(CharSequence.class), any(Throwable.class)); + assertTrue(exception.getMessage().contains(basedir.toString())); } @Test @@ -94,13 +88,9 @@ void deleteFailsAfterRetryWhenNoPermission(@TempDir Path tempDir) throws Excepti // Remove the executable flag to prevent directory listing, which will result in a DirectoryNotEmptyException. final Set permissions = PosixFilePermissions.fromString("rw-rw-r--"); setPosixFilePermissions(basedir, permissions); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); - final IOException exception = - assertThrows(IOException.class, () -> cleaner.delete(basedir, null, false, true, true)); - assertEquals("Failed to delete " + basedir, exception.getMessage()); - final DirectoryNotEmptyException cause = - assertInstanceOf(DirectoryNotEmptyException.class, exception.getCause()); - assertEquals(basedir.toString(), cause.getMessage()); + final var cleaner = new Cleaner(null, log, false, null, null, false, true, true); + final var exception = assertThrows(AccessDeniedException.class, () -> cleaner.delete(basedir)); + assertTrue(exception.getMessage().contains(basedir.toString())); } @Test @@ -112,16 +102,13 @@ void deleteLogsWarningWithoutRetryWhenNoPermission(@TempDir Path tempDir) throws // Remove the writable flag to prevent deletion of the file, which will result in an AccessDeniedException. final Set permissions = PosixFilePermissions.fromString("r-xr-xr-x"); setPosixFilePermissions(basedir, permissions); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); - assertDoesNotThrow(() -> cleaner.delete(basedir, null, false, false, false)); - verify(log, times(2)).warn(any(CharSequence.class), any(Throwable.class)); + final var cleaner = new Cleaner(null, log, false, null, null, false, false, false); + assertDoesNotThrow(() -> cleaner.delete(basedir)); + verify(log, times(1)).warn(any(CharSequence.class), any(Throwable.class)); InOrder inOrder = inOrder(log); ArgumentCaptor cause1 = ArgumentCaptor.forClass(AccessDeniedException.class); inOrder.verify(log).warn(eq("Failed to delete " + file), cause1.capture()); assertEquals(file.toString(), cause1.getValue().getMessage()); - ArgumentCaptor cause2 = ArgumentCaptor.forClass(DirectoryNotEmptyException.class); - inOrder.verify(log).warn(eq("Failed to delete " + basedir), cause2.capture()); - assertEquals(basedir.toString(), cause2.getValue().getMessage()); } @Test @@ -133,8 +120,8 @@ void deleteDoesNotLogAnythingWhenNoPermissionAndWarnDisabled(@TempDir Path tempD // Remove the writable flag to prevent deletion of the file, which will result in an AccessDeniedException. final Set permissions = PosixFilePermissions.fromString("r-xr-xr-x"); setPosixFilePermissions(basedir, permissions); - final Cleaner cleaner = new Cleaner(null, log, false, null, null); - assertDoesNotThrow(() -> cleaner.delete(basedir, null, false, false, false)); + final var cleaner = new Cleaner(null, log, false, null, null, false, false, false); + assertDoesNotThrow(() -> cleaner.delete(basedir)); verify(log, never()).warn(any(CharSequence.class), any(Throwable.class)); } }