View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one
3    * or more contributor license agreements.  See the NOTICE file
4    * distributed with this work for additional information
5    * regarding copyright ownership.  The ASF licenses this file
6    * to you under the Apache License, Version 2.0 (the
7    * "License"); you may not use this file except in compliance
8    * with the License.  You may obtain a copy of the License at
9    *
10   *   http://www.apache.org/licenses/LICENSE-2.0
11   *
12   * Unless required by applicable law or agreed to in writing,
13   * software distributed under the License is distributed on an
14   * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15   * KIND, either express or implied.  See the License for the
16   * specific language governing permissions and limitations
17   * under the License.
18   */
19  package org.apache.maven.plugins.clean;
20  
21  import java.io.File;
22  import java.io.IOException;
23  import java.lang.reflect.InvocationHandler;
24  import java.lang.reflect.Method;
25  import java.lang.reflect.Proxy;
26  import java.nio.file.AccessDeniedException;
27  import java.nio.file.DirectoryStream;
28  import java.nio.file.Files;
29  import java.nio.file.LinkOption;
30  import java.nio.file.Path;
31  import java.nio.file.StandardCopyOption;
32  import java.nio.file.attribute.BasicFileAttributes;
33  import java.nio.file.attribute.DosFileAttributeView;
34  import java.nio.file.attribute.PosixFileAttributeView;
35  import java.nio.file.attribute.PosixFilePermission;
36  import java.util.ArrayDeque;
37  import java.util.Deque;
38  import java.util.EnumSet;
39  import java.util.HashSet;
40  import java.util.Set;
41  
42  import org.apache.maven.execution.ExecutionListener;
43  import org.apache.maven.execution.MavenSession;
44  import org.apache.maven.plugin.logging.Log;
45  import org.codehaus.plexus.util.Os;
46  import org.eclipse.aether.SessionData;
47  
48  import static java.nio.file.Files.exists;
49  import static java.nio.file.Files.isDirectory;
50  import static java.nio.file.Files.newDirectoryStream;
51  import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_BACKGROUND;
52  import static org.apache.maven.plugins.clean.CleanMojo.FAST_MODE_DEFER;
53  
54  /**
55   * Cleans directories.
56   *
57   * @author Benjamin Bentmann
58   */
59  class Cleaner {
60  
61      private static final boolean ON_WINDOWS = Os.isFamily(Os.FAMILY_WINDOWS);
62  
63      private static final String LAST_DIRECTORY_TO_DELETE = Cleaner.class.getName() + ".lastDirectoryToDelete";
64  
65      /**
66       * The maven session.  This is typically non-null in a real run, but it can be during unit tests.
67       */
68      private final MavenSession session;
69  
70      private final Path fastDir;
71  
72      private final String fastMode;
73  
74      private final boolean verbose;
75  
76      private Log log;
77  
78      /**
79       * Whether to force the deletion of read-only files. Note that on Linux,
80       * {@link Files#delete(Path)} and {@link Files#deleteIfExists(Path)} delete read-only files
81       * but throw {@link AccessDeniedException} if the directory containing the file is read-only.
82       */
83      private final boolean force;
84  
85      /**
86       * Creates a new cleaner.
87       *
88       * @param session  The Maven session to be used.
89       * @param log      The logger to use.
90       * @param verbose  Whether to perform verbose logging.
91       * @param fastDir  The explicit configured directory or to be deleted in fast mode.
92       * @param fastMode The fast deletion mode.
93       * @param force    whether to force the deletion of read-only files
94       */
95      Cleaner(MavenSession session, final Log log, boolean verbose, Path fastDir, String fastMode, boolean force) {
96          this.session = session;
97          // This can't be null as the Cleaner gets it from the CleanMojo which gets it from AbstractMojo class, where it
98          // is never null.
99          this.log = log;
100         this.fastDir = fastDir;
101         this.fastMode = fastMode;
102         this.verbose = verbose;
103         this.force = force;
104     }
105 
106     /**
107      * Deletes the specified directories and its contents.
108      *
109      * @param basedir        The directory to delete, must not be <code>null</code>. Non-existing directories will be silently
110      *                       ignored.
111      * @param selector       The selector used to determine what contents to delete, may be <code>null</code> to delete
112      *                       everything.
113      * @param followSymlinks Whether to follow symlinks.
114      * @param failOnError    Whether to abort with an exception in case a selected file/directory could not be deleted.
115      * @param retryOnError   Whether to undertake additional delete attempts in case the first attempt failed.
116      * @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
117      */
118     public void delete(
119             Path basedir, Selector selector, boolean followSymlinks, boolean failOnError, boolean retryOnError)
120             throws IOException {
121         if (!isDirectory(basedir)) {
122             if (!exists(basedir)) {
123                 if (log.isDebugEnabled()) {
124                     log.debug("Skipping non-existing directory " + basedir);
125                 }
126                 return;
127             }
128             throw new IOException("Invalid base directory " + basedir);
129         }
130 
131         if (log.isInfoEnabled()) {
132             log.info("Deleting " + basedir + (selector != null ? " (" + selector + ")" : ""));
133         }
134 
135         Path file = followSymlinks ? basedir : basedir.toRealPath();
136 
137         if (selector == null && !followSymlinks && fastDir != null && session != null) {
138             // If anything wrong happens, we'll just use the usual deletion mechanism
139             if (fastDelete(file)) {
140                 return;
141             }
142         }
143 
144         delete(file, "", selector, followSymlinks, failOnError, retryOnError);
145     }
146 
147     private boolean fastDelete(Path baseDir) {
148         // Handle the case where we use ${maven.multiModuleProjectDirectory}/target/.clean for example
149         if (fastDir.toAbsolutePath().startsWith(baseDir.toAbsolutePath())) {
150             try {
151                 String prefix = baseDir.getFileName().toString() + ".";
152                 Path tmpDir = Files.createTempDirectory(baseDir.getParent(), prefix);
153                 try {
154                     Files.move(baseDir, tmpDir, StandardCopyOption.REPLACE_EXISTING);
155                     if (session != null) {
156                         session.getRepositorySession().getData().set(LAST_DIRECTORY_TO_DELETE, baseDir);
157                     }
158                     baseDir = tmpDir;
159                 } catch (IOException e) {
160                     Files.delete(tmpDir);
161                     throw e;
162                 }
163             } catch (IOException e) {
164                 if (log.isDebugEnabled()) {
165                     log.debug("Unable to fast delete directory: ", e);
166                 }
167                 return false;
168             }
169         }
170         // Create fastDir and the needed parents if needed
171         try {
172             if (!isDirectory(fastDir)) {
173                 Files.createDirectories(fastDir);
174             }
175         } catch (IOException e) {
176             if (log.isDebugEnabled()) {
177                 log.debug(
178                         "Unable to fast delete directory as the path " + fastDir
179                                 + " does not point to a directory or cannot be created: ",
180                         e);
181             }
182             return false;
183         }
184 
185         try {
186             Path tmpDir = Files.createTempDirectory(fastDir, "");
187             Path dstDir = tmpDir.resolve(baseDir.getFileName());
188             // Note that by specifying the ATOMIC_MOVE, we expect an exception to be thrown
189             // if the path leads to a directory on another mountpoint.  If this is the case
190             // or any other exception occurs, an exception will be thrown in which case
191             // the method will return false and the usual deletion will be performed.
192             Files.move(baseDir, dstDir, StandardCopyOption.ATOMIC_MOVE);
193             BackgroundCleaner.delete(this, tmpDir, fastMode);
194             return true;
195         } catch (IOException e) {
196             if (log.isDebugEnabled()) {
197                 log.debug("Unable to fast delete directory: ", e);
198             }
199             return false;
200         }
201     }
202 
203     /**
204      * Deletes the specified file or directory.
205      *
206      * @param file           The file/directory to delete, must not be <code>null</code>. If <code>followSymlinks</code> is
207      *                       <code>false</code>, it is assumed that the parent file is canonical.
208      * @param pathname       The relative pathname of the file, using {@link File#separatorChar}, must not be
209      *                       <code>null</code>.
210      * @param selector       The selector used to determine what contents to delete, may be <code>null</code> to delete
211      *                       everything.
212      * @param followSymlinks Whether to follow symlinks.
213      * @param failOnError    Whether to abort with an exception in case a selected file/directory could not be deleted.
214      * @param retryOnError   Whether to undertake additional delete attempts in case the first attempt failed.
215      * @return The result of the cleaning, never <code>null</code>.
216      * @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
217      */
218     private Result delete(
219             Path file,
220             String pathname,
221             Selector selector,
222             boolean followSymlinks,
223             boolean failOnError,
224             boolean retryOnError)
225             throws IOException {
226         Result result = new Result();
227 
228         boolean isDirectory = isDirectory(file);
229 
230         if (isDirectory) {
231             if (selector == null || selector.couldHoldSelected(pathname)) {
232                 if (followSymlinks || !isSymbolicLink(file)) {
233                     Path canonical = followSymlinks ? file : file.toRealPath();
234                     String prefix = pathname.length() > 0 ? pathname + File.separatorChar : "";
235                     try (DirectoryStream<Path> children = newDirectoryStream(canonical)) {
236                         for (Path child : children) {
237                             String filename = child.getFileName().toString();
238                             result.update(delete(
239                                     child, prefix + filename, selector, followSymlinks, failOnError, retryOnError));
240                         }
241                     }
242                 } else if (log.isDebugEnabled()) {
243                     log.debug("Not recursing into symlink " + file);
244                 }
245             } else if (log.isDebugEnabled()) {
246                 log.debug("Not recursing into directory without included files " + file);
247             }
248         }
249 
250         if (!result.excluded && (selector == null || selector.isSelected(pathname))) {
251             String logmessage;
252             if (isDirectory) {
253                 logmessage = "Deleting directory " + file;
254             } else if (exists(file)) {
255                 logmessage = "Deleting file " + file;
256             } else {
257                 logmessage = "Deleting dangling symlink " + file;
258             }
259 
260             if (verbose && log.isInfoEnabled()) {
261                 log.info(logmessage);
262             } else if (log.isDebugEnabled()) {
263                 log.debug(logmessage);
264             }
265 
266             result.failures += delete(file, failOnError, retryOnError);
267         } else {
268             result.excluded = true;
269         }
270 
271         return result;
272     }
273 
274     private boolean isSymbolicLink(Path path) throws IOException {
275         BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class, LinkOption.NOFOLLOW_LINKS);
276         return attrs.isSymbolicLink()
277                 // MCLEAN-93: NTFS junctions have isDirectory() and isOther() attributes set
278                 || (attrs.isDirectory() && attrs.isOther());
279     }
280 
281     /**
282      * Makes the given file or directory writable.
283      * If the file is already writable, then this method tries to make the parent directory writable.
284      *
285      * @param file the path to the file or directory to make writable, or {@code null} if none
286      * @return the root path which has been made writable, or {@code null} if none
287      */
288     private static Path setWritable(Path file) throws IOException {
289         while (file != null) {
290             PosixFileAttributeView posix = Files.getFileAttributeView(file, PosixFileAttributeView.class);
291             if (posix != null) {
292                 EnumSet<PosixFilePermission> permissions =
293                         EnumSet.copyOf(posix.readAttributes().permissions());
294                 if (permissions.add(PosixFilePermission.OWNER_WRITE)) {
295                     posix.setPermissions(permissions);
296                     return file;
297                 }
298             } else {
299                 DosFileAttributeView dos = Files.getFileAttributeView(file, DosFileAttributeView.class);
300                 if (dos == null) {
301                     return null; // Unknown type of file attributes.
302                 }
303                 // No need to update the parent directory because DOS read-only attribute does not apply to folders.
304                 dos.setReadOnly(false);
305                 return file;
306             }
307             // File was already writable. Maybe it is the parent directory which was not writable.
308             file = file.getParent();
309         }
310         return null;
311     }
312 
313     /**
314      * Deletes the specified file, directory. If the path denotes a symlink, only the link is removed, its target is
315      * left untouched.
316      *
317      * @param file         The file/directory to delete, must not be <code>null</code>.
318      * @param failOnError  Whether to abort with an exception in case the file/directory could not be deleted.
319      * @param retryOnError Whether to undertake additional delete attempts in case the first attempt failed.
320      * @return <code>0</code> if the file was deleted, <code>1</code> otherwise.
321      * @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
322      */
323     private int delete(Path file, boolean failOnError, boolean retryOnError) throws IOException {
324         IOException failure = delete(file);
325         if (failure != null) {
326             boolean deleted = false;
327             boolean tryWritable = force && failure instanceof AccessDeniedException;
328             if (tryWritable || retryOnError) {
329                 final Set<Path> madeWritable; // Safety against never-ending loops.
330                 if (force) {
331                     madeWritable = new HashSet<>();
332                     madeWritable.add(null); // For having `add(null)` to return `false`.
333                 } else {
334                     madeWritable = null;
335                 }
336                 final int[] delays = {50, 250, 750};
337                 int delayIndex = 0;
338                 while (!deleted && delayIndex < delays.length) {
339                     if (tryWritable) {
340                         tryWritable = madeWritable.add(setWritable(file));
341                         // `true` if we successfully changed permission, in which case we will skip the delay.
342                     }
343                     if (!tryWritable) {
344                         if (ON_WINDOWS) {
345                             // Try to release any locks held by non-closed files.
346                             System.gc();
347                         }
348                         try {
349                             Thread.sleep(delays[delayIndex++]);
350                         } catch (InterruptedException e) {
351                             // ignore
352                         }
353                     }
354                     deleted = delete(file) == null || !exists(file);
355                     tryWritable = !deleted && force && failure instanceof AccessDeniedException;
356                 }
357             } else {
358                 deleted = !exists(file);
359             }
360 
361             if (!deleted) {
362                 if (failOnError) {
363                     throw new IOException("Failed to delete " + file, failure);
364                 } else {
365                     if (log.isWarnEnabled()) {
366                         log.warn("Failed to delete " + file, failure);
367                     }
368                     return 1;
369                 }
370             }
371         }
372 
373         return 0;
374     }
375 
376     private static IOException delete(Path file) {
377         try {
378             Files.delete(file);
379         } catch (IOException e) {
380             return e;
381         }
382         return null;
383     }
384 
385     private static class Result {
386 
387         private int failures;
388 
389         private boolean excluded;
390 
391         public void update(Result result) {
392             failures += result.failures;
393             excluded |= result.excluded;
394         }
395     }
396 
397     private static class BackgroundCleaner extends Thread {
398 
399         private static final int NEW = 0;
400         private static final int RUNNING = 1;
401         private static final int STOPPED = 2;
402         private static BackgroundCleaner instance;
403         private final Deque<Path> filesToDelete = new ArrayDeque<>();
404         private final Cleaner cleaner;
405         private final String fastMode;
406         private int status = NEW;
407 
408         private BackgroundCleaner(Cleaner cleaner, Path dir, String fastMode) throws IOException {
409             super("mvn-background-cleaner");
410             this.cleaner = cleaner;
411             this.fastMode = fastMode;
412             init(cleaner.fastDir, dir);
413         }
414 
415         public static void delete(Cleaner cleaner, Path dir, String fastMode) throws IOException {
416             synchronized (BackgroundCleaner.class) {
417                 if (instance == null || !instance.doDelete(dir)) {
418                     instance = new BackgroundCleaner(cleaner, dir, fastMode);
419                 }
420             }
421         }
422 
423         static void sessionEnd() {
424             synchronized (BackgroundCleaner.class) {
425                 if (instance != null) {
426                     instance.doSessionEnd();
427                 }
428             }
429         }
430 
431         public void run() {
432             while (true) {
433                 Path basedir = pollNext();
434                 if (basedir == null) {
435                     break;
436                 }
437                 try {
438                     cleaner.delete(basedir, "", null, false, false, true);
439                 } catch (IOException e) {
440                     // do not display errors
441                 }
442             }
443         }
444 
445         synchronized void init(Path fastDir, Path dir) throws IOException {
446             if (isDirectory(fastDir)) {
447                 try (DirectoryStream<Path> children = newDirectoryStream(fastDir)) {
448                     for (Path child : children) {
449                         doDelete(child);
450                     }
451                 }
452             }
453             doDelete(dir);
454         }
455 
456         synchronized Path pollNext() {
457             Path basedir = filesToDelete.poll();
458             if (basedir == null) {
459                 if (cleaner.session != null) {
460                     SessionData data = cleaner.session.getRepositorySession().getData();
461                     Path lastDir = (Path) data.get(LAST_DIRECTORY_TO_DELETE);
462                     if (lastDir != null) {
463                         data.set(LAST_DIRECTORY_TO_DELETE, null);
464                         return lastDir;
465                     }
466                 }
467                 status = STOPPED;
468                 notifyAll();
469             }
470             return basedir;
471         }
472 
473         synchronized boolean doDelete(Path dir) {
474             if (status == STOPPED) {
475                 return false;
476             }
477             filesToDelete.add(dir);
478             if (status == NEW && FAST_MODE_BACKGROUND.equals(fastMode)) {
479                 status = RUNNING;
480                 notifyAll();
481                 start();
482             }
483             wrapExecutionListener();
484             return true;
485         }
486 
487         /**
488          * If this has not been done already, we wrap the ExecutionListener inside a proxy
489          * which simply delegates call to the previous listener.  When the session ends, it will
490          * also call {@link BackgroundCleaner#sessionEnd()}.
491          * There's no clean API to do that properly as this is a very unusual use case for a plugin
492          * to outlive its main execution.
493          */
494         private void wrapExecutionListener() {
495             ExecutionListener executionListener = cleaner.session.getRequest().getExecutionListener();
496             if (executionListener == null
497                     || !Proxy.isProxyClass(executionListener.getClass())
498                     || !(Proxy.getInvocationHandler(executionListener) instanceof SpyInvocationHandler)) {
499                 ExecutionListener listener = (ExecutionListener) Proxy.newProxyInstance(
500                         ExecutionListener.class.getClassLoader(),
501                         new Class[] {ExecutionListener.class},
502                         new SpyInvocationHandler(executionListener));
503                 cleaner.session.getRequest().setExecutionListener(listener);
504             }
505         }
506 
507         synchronized void doSessionEnd() {
508             if (status != STOPPED) {
509                 if (status == NEW) {
510                     start();
511                 }
512                 if (!FAST_MODE_DEFER.equals(fastMode)) {
513                     try {
514                         if (cleaner.log.isInfoEnabled()) {
515                             cleaner.log.info("Waiting for background file deletion");
516                         }
517                         while (status != STOPPED) {
518                             wait();
519                         }
520                     } catch (InterruptedException e) {
521                         // ignore
522                     }
523                 }
524             }
525         }
526     }
527 
528     static class SpyInvocationHandler implements InvocationHandler {
529         private final ExecutionListener delegate;
530 
531         SpyInvocationHandler(ExecutionListener delegate) {
532             this.delegate = delegate;
533         }
534 
535         @Override
536         public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
537             if ("sessionEnded".equals(method.getName())) {
538                 BackgroundCleaner.sessionEnd();
539             }
540             if (delegate != null) {
541                 return method.invoke(delegate, args);
542             }
543             return null;
544         }
545     }
546 }