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