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.eclipse.aether.spi.io;
20  
21  import java.io.BufferedInputStream;
22  import java.io.BufferedOutputStream;
23  import java.io.IOException;
24  import java.io.InputStream;
25  import java.io.InterruptedIOException;
26  import java.io.OutputStream;
27  import java.nio.ByteBuffer;
28  import java.nio.charset.StandardCharsets;
29  import java.nio.file.AccessDeniedException;
30  import java.nio.file.AtomicMoveNotSupportedException;
31  import java.nio.file.FileSystemException;
32  import java.nio.file.Files;
33  import java.nio.file.Path;
34  import java.nio.file.StandardCopyOption;
35  import java.nio.file.attribute.FileTime;
36  import java.util.concurrent.ThreadLocalRandom;
37  import java.util.concurrent.atomic.AtomicBoolean;
38  
39  import static java.util.Objects.requireNonNull;
40  
41  /**
42   * Utility class serving as base of {@link PathProcessor} implementations. This class can be extended or replaced
43   * (as component) when needed. Also, this class is published in Resolver implementation for path processor interface.
44   *
45   * @since 2.0.13
46   */
47  public class PathProcessorSupport implements PathProcessor {
48      /**
49       * Logic borrowed from Commons-Lang3: we really need only this, to decide do we NIO2 file ops or not.
50       * On Windows the final move needs retry/staging logic, see {@link #retryingMove(Path, Path, StandardCopyOption[])}.
51       */
52      protected static final boolean IS_WINDOWS =
53              System.getProperty("os.name", "unknown").startsWith("Windows");
54  
55      /**
56       * Escape hatch if atomic move is not desired on system we run on.
57       */
58      protected static final boolean ATOMIC_MOVE =
59              Boolean.parseBoolean(System.getProperty(PathProcessor.class.getName() + "ATOMIC_MOVE", "true"));
60  
61      /**
62       * The number of attempts for the final move on Windows, where the move target may be transiently locked by a
63       * virus scanner, an indexer or a concurrent reader.
64       */
65      protected static final int WINDOWS_MOVE_ATTEMPTS =
66              Math.max(1, Integer.getInteger(PathProcessor.class.getName() + "WINDOWS_MOVE_ATTEMPTS", 5));
67  
68      /**
69       * The delay in milliseconds applied between the move attempts on Windows.
70       */
71      protected static final long WINDOWS_MOVE_RETRY_DELAY =
72              Long.getLong(PathProcessor.class.getName() + "WINDOWS_MOVE_RETRY_DELAY", 50L);
73  
74      @Override
75      public boolean setLastModified(Path path, long value) throws IOException {
76          try {
77              Files.setLastModifiedTime(path, FileTime.fromMillis(value));
78              return true;
79          } catch (FileSystemException e) {
80              // MRESOLVER-536: Java uses generic FileSystemException for some weird cases,
81              // but some subclasses like AccessDeniedEx should be re-thrown
82              if (e instanceof AccessDeniedException) {
83                  throw e;
84              }
85              return false;
86          }
87      }
88  
89      @Override
90      public void write(Path target, String data) throws IOException {
91          writeFile(target, p -> Files.write(p, data.getBytes(StandardCharsets.UTF_8)), false);
92      }
93  
94      @Override
95      public void write(Path target, InputStream source) throws IOException {
96          writeFile(target, p -> Files.copy(source, p, StandardCopyOption.REPLACE_EXISTING), false);
97      }
98  
99      @Override
100     public void writeWithBackup(Path target, String data) throws IOException {
101         writeFile(target, p -> Files.write(p, data.getBytes(StandardCharsets.UTF_8)), true);
102     }
103 
104     @Override
105     public void writeWithBackup(Path target, InputStream source) throws IOException {
106         writeFile(target, p -> Files.copy(source, p, StandardCopyOption.REPLACE_EXISTING), true);
107     }
108 
109     /**
110      * A file writer, that accepts a {@link Path} to write some content to. Note: the file denoted by path may exist,
111      * hence implementation have to ensure it is able to achieve its goal ("replace existing" option or equivalent
112      * should be used).
113      */
114     @FunctionalInterface
115     public interface FileWriter {
116         void write(Path path) throws IOException;
117     }
118 
119     /**
120      * Utility method to write out file to disk in "atomic" manner, with optional backups (".bak") if needed. This
121      * ensures that no other thread or process will be able to read not fully written files. Finally, this method
122      * may create the needed parent directories, if the passed in target parents does not exist.
123      *
124      * @param target   that is the target file (must be an existing or non-existing file, the path must have parent)
125      * @param writer   the writer that will accept a {@link Path} to write content to
126      * @param doBackup if {@code true}, and target file is about to be overwritten, a ".bak" file with old contents will
127      *                 be created/overwritten
128      * @throws IOException if at any step IO problem occurs
129      */
130     public void writeFile(Path target, FileWriter writer, boolean doBackup) throws IOException {
131         requireNonNull(target, "target is null");
132         requireNonNull(writer, "writer is null");
133         Path parent = requireNonNull(target.getParent(), "target must have parent");
134 
135         try (CollocatedTempFile tempFile = newTempFile(target)) {
136             writer.write(tempFile.getPath());
137             if (doBackup && Files.isRegularFile(target)) {
138                 Files.copy(target, parent.resolve(target.getFileName() + ".bak"), StandardCopyOption.REPLACE_EXISTING);
139             }
140             tempFile.move();
141         }
142     }
143 
144     @Override
145     public long copy(Path source, Path target, ProgressListener listener) throws IOException {
146         try (InputStream in = new BufferedInputStream(Files.newInputStream(source));
147                 CollocatedTempFile tempTarget = newTempFile(target);
148                 OutputStream out = new BufferedOutputStream(Files.newOutputStream(tempTarget.getPath()))) {
149             long result = copy(out, in, listener);
150             tempTarget.move();
151             return result;
152         }
153     }
154 
155     private long copy(OutputStream os, InputStream is, ProgressListener listener) throws IOException {
156         long total = 0L;
157         byte[] buffer = new byte[1024 * 32];
158         while (true) {
159             int bytes = is.read(buffer);
160             if (bytes < 0) {
161                 break;
162             }
163 
164             os.write(buffer, 0, bytes);
165 
166             total += bytes;
167 
168             if (listener != null && bytes > 0) {
169                 try {
170                     listener.progressed(ByteBuffer.wrap(buffer, 0, bytes));
171                 } catch (Exception e) {
172                     // too bad
173                 }
174             }
175         }
176 
177         return total;
178     }
179 
180     @Override
181     public void move(Path source, Path target) throws IOException {
182         final StandardCopyOption[] copyOption = ATOMIC_MOVE
183                 ? new StandardCopyOption[] {
184                     StandardCopyOption.ATOMIC_MOVE,
185                     StandardCopyOption.REPLACE_EXISTING,
186                     StandardCopyOption.COPY_ATTRIBUTES
187                 }
188                 : new StandardCopyOption[] {StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES};
189         if (IS_WINDOWS) {
190             retryingMove(source, target, copyOption);
191         } else {
192             Files.move(source, target, copyOption);
193         }
194         Files.deleteIfExists(source);
195     }
196 
197     // Temp files
198 
199     @Override
200     public TempFile newTempFile() throws IOException {
201         Path tempFile = Files.createTempFile("resolver", "tmp");
202         return new TempFile() {
203             @Override
204             public Path getPath() {
205                 return tempFile;
206             }
207 
208             @Override
209             public void close() throws IOException {
210                 Files.deleteIfExists(tempFile);
211             }
212         };
213     }
214 
215     @Override
216     public CollocatedTempFile newTempFile(Path file) throws IOException {
217         Path parent = requireNonNull(file.getParent(), "file must have parent");
218         Files.createDirectories(parent);
219         Path tempFile = parent.resolve(file.getFileName() + "."
220                 + Long.toUnsignedString(ThreadLocalRandom.current().nextLong()) + ".tmp");
221         return new CollocatedTempFile() {
222             private final AtomicBoolean wantsMove = new AtomicBoolean(false);
223             private final StandardCopyOption[] copyOption = ATOMIC_MOVE
224                     ? new StandardCopyOption[] {StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING}
225                     : new StandardCopyOption[] {StandardCopyOption.REPLACE_EXISTING};
226 
227             @Override
228             public Path getPath() {
229                 return tempFile;
230             }
231 
232             @Override
233             public void move() {
234                 wantsMove.set(true);
235             }
236 
237             @Override
238             public void close() throws IOException {
239                 if (wantsMove.get()) {
240                     if (IS_WINDOWS) {
241                         retryingMove(tempFile, file, copyOption);
242                     } else {
243                         Files.move(tempFile, file, copyOption);
244                     }
245                 }
246                 Files.deleteIfExists(tempFile);
247             }
248         };
249     }
250 
251     /**
252      * Moves the source file to the target path without ever truncating the target in place: the content visible at
253      * the target path is either the old file or the complete new file, never a partially written one.
254      * <p>
255      * Historically, on Windows the final move was implemented by opening the target path with a truncating stream
256      * and copying the source into it ({@link #classicCopy(Path, Path)}). That left a window during which a
257      * concurrent reader (for example a forked JVM resolving the same artifact) or an untimely process kill would
258      * observe, or durably leave behind, a truncated file at the final path - after checksum validation has already
259      * happened, so the torn content would from then on be trusted as verified. Instead, this method attempts the
260      * rename a bounded number of times ({@link #WINDOWS_MOVE_ATTEMPTS}, file locking by virus scanners, indexers or
261      * concurrent readers is transient on Windows), and if the file system refuses an atomic move (for example when
262      * source and target are on different stores), the source is first staged into a collocated temporary file next
263      * to the target and then renamed into place - the target path itself is never opened for writing.
264      */
265     protected void retryingMove(Path source, Path target, StandardCopyOption[] copyOptions) throws IOException {
266         FileSystemException lastException = null;
267         for (int attempt = 0; attempt < WINDOWS_MOVE_ATTEMPTS; attempt++) {
268             try {
269                 fileSystemMove(source, target, copyOptions);
270                 return;
271             } catch (AtomicMoveNotSupportedException e) {
272                 lastException = e;
273                 break; // retrying will not help; stage a collocated copy instead, see below
274             } catch (FileSystemException e) {
275                 // covers AccessDeniedException and sharing violations: transient on Windows
276                 lastException = e;
277                 try {
278                     Thread.sleep(WINDOWS_MOVE_RETRY_DELAY);
279                 } catch (InterruptedException ie) {
280                     Thread.currentThread().interrupt();
281                     InterruptedIOException interrupted =
282                             new InterruptedIOException("Interrupted while moving " + source + " to " + target);
283                     interrupted.initCause(ie);
284                     interrupted.addSuppressed(e);
285                     throw interrupted;
286                 }
287             }
288         }
289         if (lastException instanceof AtomicMoveNotSupportedException) {
290             Path staged = target.resolveSibling(target.getFileName() + "."
291                     + Long.toUnsignedString(ThreadLocalRandom.current().nextLong()) + ".tmp");
292             try {
293                 classicCopy(source, staged);
294                 fileSystemMove(staged, target, copyOptions);
295                 return;
296             } finally {
297                 Files.deleteIfExists(staged);
298             }
299         }
300         throw lastException;
301     }
302 
303     /**
304      * Testable seam for {@link Files#move(Path, Path, java.nio.file.CopyOption...)}.
305      */
306     protected void fileSystemMove(Path source, Path target, StandardCopyOption... copyOptions) throws IOException {
307         Files.move(source, target, copyOptions);
308     }
309 
310     /**
311      * Pre-NIO2 way to copy files. Important: this method must never be pointed at a "final" (published) path, as it
312      * opens the target with truncation; callers stage into a temporary file and rename into place instead.
313      */
314     protected void classicCopy(Path source, Path target) throws IOException {
315         ByteBuffer buffer = ByteBuffer.allocate(1024 * 32);
316         byte[] array = buffer.array();
317         try (InputStream is = Files.newInputStream(source);
318                 OutputStream os = Files.newOutputStream(target)) {
319             while (true) {
320                 int bytes = is.read(array);
321                 if (bytes < 0) {
322                     break;
323                 }
324                 os.write(array, 0, bytes);
325             }
326         }
327     }
328 }