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