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