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 Logger logDebug;
60  
61      private final Logger logInfo;
62  
63      private final Logger logVerbose;
64  
65      private final Logger logWarn;
66  
67      private final File fastDir;
68  
69      private final String fastMode;
70  
71      /**
72       * Creates a new cleaner.
73       * @param log The logger to use, may be <code>null</code> to disable logging.
74       * @param verbose Whether to perform verbose logging.
75       * @param fastMode The fast deletion mode
76       */
77      Cleaner(MavenSession session, final Log log, boolean verbose, File fastDir, String fastMode) {
78          logDebug = (log == null || !log.isDebugEnabled()) ? null : log::debug;
79  
80          logInfo = (log == null || !log.isInfoEnabled()) ? null : log::info;
81  
82          logWarn = (log == null || !log.isWarnEnabled()) ? null : log::warn;
83  
84          logVerbose = verbose ? logInfo : logDebug;
85  
86          this.session = session;
87          this.fastDir = fastDir;
88          this.fastMode = fastMode;
89      }
90  
91      /**
92       * Deletes the specified directories and its contents.
93       *
94       * @param basedir The directory to delete, must not be <code>null</code>. Non-existing directories will be silently
95       *            ignored.
96       * @param selector The selector used to determine what contents to delete, may be <code>null</code> to delete
97       *            everything.
98       * @param followSymlinks Whether to follow symlinks.
99       * @param failOnError Whether to abort with an exception in case a selected file/directory could not be deleted.
100      * @param retryOnError Whether to undertake additional delete attempts in case the first attempt failed.
101      * @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
102      */
103     public void delete(
104             File basedir, Selector selector, boolean followSymlinks, boolean failOnError, boolean retryOnError)
105             throws IOException {
106         if (!basedir.isDirectory()) {
107             if (!basedir.exists()) {
108                 if (logDebug != null) {
109                     logDebug.log("Skipping non-existing directory " + basedir);
110                 }
111                 return;
112             }
113             throw new IOException("Invalid base directory " + basedir);
114         }
115 
116         if (logInfo != null) {
117             logInfo.log("Deleting " + basedir + (selector != null ? " (" + selector + ")" : ""));
118         }
119 
120         File file = followSymlinks ? basedir : basedir.getCanonicalFile();
121 
122         if (selector == null && !followSymlinks && fastDir != null && session != null) {
123             // If anything wrong happens, we'll just use the usual deletion mechanism
124             if (fastDelete(file)) {
125                 return;
126             }
127         }
128 
129         delete(file, "", selector, followSymlinks, failOnError, retryOnError);
130     }
131 
132     private boolean fastDelete(File baseDirFile) {
133         Path baseDir = baseDirFile.toPath();
134         Path fastDir = this.fastDir.toPath();
135         // Handle the case where we use ${maven.multiModuleProjectDirectory}/target/.clean for example
136         if (fastDir.toAbsolutePath().startsWith(baseDir.toAbsolutePath())) {
137             try {
138                 String prefix = baseDir.getFileName().toString() + ".";
139                 Path tmpDir = Files.createTempDirectory(baseDir.getParent(), prefix);
140                 try {
141                     Files.move(baseDir, tmpDir, StandardCopyOption.REPLACE_EXISTING);
142                     if (session != null) {
143                         session.getRepositorySession().getData().set(LAST_DIRECTORY_TO_DELETE, baseDir.toFile());
144                     }
145                     baseDir = tmpDir;
146                 } catch (IOException e) {
147                     Files.delete(tmpDir);
148                     throw e;
149                 }
150             } catch (IOException e) {
151                 if (logDebug != null) {
152                     // TODO: this Logger interface cannot log exceptions and needs refactoring
153                     logDebug.log("Unable to fast delete directory: " + e);
154                 }
155                 return false;
156             }
157         }
158         // Create fastDir and the needed parents if needed
159         try {
160             if (!Files.isDirectory(fastDir)) {
161                 Files.createDirectories(fastDir);
162             }
163         } catch (IOException e) {
164             if (logDebug != null) {
165                 // TODO: this Logger interface cannot log exceptions and needs refactoring
166                 logDebug.log("Unable to fast delete directory as the path " + fastDir
167                         + " does not point to a directory or cannot be created: " + e);
168             }
169             return false;
170         }
171 
172         try {
173             Path tmpDir = Files.createTempDirectory(fastDir, "");
174             Path dstDir = tmpDir.resolve(baseDir.getFileName());
175             // Note that by specifying the ATOMIC_MOVE, we expect an exception to be thrown
176             // if the path leads to a directory on another mountpoint.  If this is the case
177             // or any other exception occurs, an exception will be thrown in which case
178             // the method will return false and the usual deletion will be performed.
179             Files.move(baseDir, dstDir, StandardCopyOption.ATOMIC_MOVE);
180             BackgroundCleaner.delete(this, tmpDir.toFile(), fastMode);
181             return true;
182         } catch (IOException e) {
183             if (logDebug != null) {
184                 // TODO: this Logger interface cannot log exceptions and needs refactoring
185                 logDebug.log("Unable to fast delete directory: " + e);
186             }
187             return false;
188         }
189     }
190 
191     /**
192      * Deletes the specified file or directory.
193      *
194      * @param file The file/directory to delete, must not be <code>null</code>. If <code>followSymlinks</code> is
195      *            <code>false</code>, it is assumed that the parent file is canonical.
196      * @param pathname The relative pathname of the file, using {@link File#separatorChar}, must not be
197      *            <code>null</code>.
198      * @param selector The selector used to determine what contents to delete, may be <code>null</code> to delete
199      *            everything.
200      * @param followSymlinks Whether to follow symlinks.
201      * @param failOnError Whether to abort with an exception in case a selected file/directory could not be deleted.
202      * @param retryOnError Whether to undertake additional delete attempts in case the first attempt failed.
203      * @return The result of the cleaning, never <code>null</code>.
204      * @throws IOException If a file/directory could not be deleted and <code>failOnError</code> is <code>true</code>.
205      */
206     private Result delete(
207             File file,
208             String pathname,
209             Selector selector,
210             boolean followSymlinks,
211             boolean failOnError,
212             boolean retryOnError)
213             throws IOException {
214         Result result = new Result();
215 
216         boolean isDirectory = file.isDirectory();
217 
218         if (isDirectory) {
219             if (selector == null || selector.couldHoldSelected(pathname)) {
220                 if (followSymlinks || !isSymbolicLink(file.toPath())) {
221                     File canonical = followSymlinks ? file : file.getCanonicalFile();
222                     String[] filenames = canonical.list();
223                     if (filenames != null) {
224                         String prefix = pathname.length() > 0 ? pathname + File.separatorChar : "";
225                         for (int i = filenames.length - 1; i >= 0; i--) {
226                             String filename = filenames[i];
227                             File child = new File(canonical, filename);
228                             result.update(delete(
229                                     child, prefix + filename, selector, followSymlinks, failOnError, retryOnError));
230                         }
231                     }
232                 } else if (logDebug != null) {
233                     logDebug.log("Not recursing into symlink " + file);
234                 }
235             } else if (logDebug != null) {
236                 logDebug.log("Not recursing into directory without included files " + file);
237             }
238         }
239 
240         if (!result.excluded && (selector == null || selector.isSelected(pathname))) {
241             if (logVerbose != null) {
242                 if (isDirectory) {
243                     logVerbose.log("Deleting directory " + file);
244                 } else if (file.exists()) {
245                     logVerbose.log("Deleting file " + file);
246                 } else {
247                     logVerbose.log("Deleting dangling symlink " + file);
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 (logWarn != null) {
303                         logWarn.log("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 interface Logger {
326 
327         void log(CharSequence message);
328     }
329 
330     private static class BackgroundCleaner extends Thread {
331 
332         private static BackgroundCleaner instance;
333 
334         private final Deque<File> filesToDelete = new ArrayDeque<>();
335 
336         private final Cleaner cleaner;
337 
338         private final String fastMode;
339 
340         private static final int NEW = 0;
341         private static final int RUNNING = 1;
342         private static final int STOPPED = 2;
343 
344         private int status = NEW;
345 
346         public static void delete(Cleaner cleaner, File dir, String fastMode) {
347             synchronized (BackgroundCleaner.class) {
348                 if (instance == null || !instance.doDelete(dir)) {
349                     instance = new BackgroundCleaner(cleaner, dir, fastMode);
350                 }
351             }
352         }
353 
354         static void sessionEnd() {
355             synchronized (BackgroundCleaner.class) {
356                 if (instance != null) {
357                     instance.doSessionEnd();
358                 }
359             }
360         }
361 
362         private BackgroundCleaner(Cleaner cleaner, File dir, String fastMode) {
363             super("mvn-background-cleaner");
364             this.cleaner = cleaner;
365             this.fastMode = fastMode;
366             init(cleaner.fastDir, dir);
367         }
368 
369         public void run() {
370             while (true) {
371                 File 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(File fastDir, File dir) {
384             if (fastDir.isDirectory()) {
385                 File[] children = fastDir.listFiles();
386                 if (children != null && children.length > 0) {
387                     for (File child : children) {
388                         doDelete(child);
389                     }
390                 }
391             }
392             doDelete(dir);
393         }
394 
395         synchronized File pollNext() {
396             File basedir = filesToDelete.poll();
397             if (basedir == null) {
398                 if (cleaner.session != null) {
399                     SessionData data = cleaner.session.getRepositorySession().getData();
400                     File lastDir = (File) data.get(LAST_DIRECTORY_TO_DELETE);
401                     if (lastDir != null) {
402                         data.set(LAST_DIRECTORY_TO_DELETE, null);
403                         return lastDir;
404                     }
405                 }
406                 status = STOPPED;
407                 notifyAll();
408             }
409             return basedir;
410         }
411 
412         synchronized boolean doDelete(File dir) {
413             if (status == STOPPED) {
414                 return false;
415             }
416             filesToDelete.add(dir);
417             if (status == NEW && FAST_MODE_BACKGROUND.equals(fastMode)) {
418                 status = RUNNING;
419                 notifyAll();
420                 start();
421             }
422             wrapExecutionListener();
423             return true;
424         }
425 
426         /**
427          * If this has not been done already, we wrap the ExecutionListener inside a proxy
428          * which simply delegates call to the previous listener.  When the session ends, it will
429          * also call {@link BackgroundCleaner#sessionEnd()}.
430          * There's no clean API to do that properly as this is a very unusual use case for a plugin
431          * to outlive its main execution.
432          */
433         private void wrapExecutionListener() {
434             ExecutionListener executionListener = cleaner.session.getRequest().getExecutionListener();
435             if (executionListener == null
436                     || !Proxy.isProxyClass(executionListener.getClass())
437                     || !(Proxy.getInvocationHandler(executionListener) instanceof SpyInvocationHandler)) {
438                 ExecutionListener listener = (ExecutionListener) Proxy.newProxyInstance(
439                         ExecutionListener.class.getClassLoader(),
440                         new Class[] {ExecutionListener.class},
441                         new SpyInvocationHandler(executionListener));
442                 cleaner.session.getRequest().setExecutionListener(listener);
443             }
444         }
445 
446         synchronized void doSessionEnd() {
447             if (status != STOPPED) {
448                 if (status == NEW) {
449                     start();
450                 }
451                 if (!FAST_MODE_DEFER.equals(fastMode)) {
452                     try {
453                         if (cleaner.logInfo != null) {
454                             cleaner.logInfo.log("Waiting for background file deletion");
455                         }
456                         while (status != STOPPED) {
457                             wait();
458                         }
459                     } catch (InterruptedException e) {
460                         // ignore
461                     }
462                 }
463             }
464         }
465     }
466 
467     static class SpyInvocationHandler implements InvocationHandler {
468         private final ExecutionListener delegate;
469 
470         SpyInvocationHandler(ExecutionListener delegate) {
471             this.delegate = delegate;
472         }
473 
474         @Override
475         public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
476             if ("sessionEnded".equals(method.getName())) {
477                 BackgroundCleaner.sessionEnd();
478             }
479             if (delegate != null) {
480                 return method.invoke(delegate, args);
481             }
482             return null;
483         }
484     }
485 }