001/* 002 * Licensed to the Apache Software Foundation (ASF) under one 003 * or more contributor license agreements. See the NOTICE file 004 * distributed with this work for additional information 005 * regarding copyright ownership. The ASF licenses this file 006 * to you under the Apache License, Version 2.0 (the 007 * "License"); you may not use this file except in compliance 008 * with the License. You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, 013 * software distributed under the License is distributed on an 014 * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY 015 * KIND, either express or implied. See the License for the 016 * specific language governing permissions and limitations 017 * under the License. 018 */ 019package org.eclipse.aether.spi.io; 020 021import java.io.BufferedInputStream; 022import java.io.BufferedOutputStream; 023import java.io.IOException; 024import java.io.InputStream; 025import java.io.InterruptedIOException; 026import java.io.OutputStream; 027import java.nio.ByteBuffer; 028import java.nio.charset.StandardCharsets; 029import java.nio.file.AccessDeniedException; 030import java.nio.file.AtomicMoveNotSupportedException; 031import java.nio.file.FileSystemException; 032import java.nio.file.Files; 033import java.nio.file.Path; 034import java.nio.file.StandardCopyOption; 035import java.nio.file.attribute.FileTime; 036import java.util.concurrent.ThreadLocalRandom; 037import java.util.concurrent.atomic.AtomicBoolean; 038 039import org.slf4j.Logger; 040import org.slf4j.LoggerFactory; 041 042import static java.util.Objects.requireNonNull; 043 044/** 045 * Utility class serving as base of {@link PathProcessor} implementations. This class can be extended or replaced 046 * (as component) when needed. Also, this class is published in Resolver implementation for path processor interface. 047 * 048 * @since 2.0.13 049 */ 050public class PathProcessorSupport implements PathProcessor { 051 private static final Logger LOGGER = LoggerFactory.getLogger(PathProcessorSupport.class); 052 053 /** 054 * Logic borrowed from Commons-Lang3: we really need only this, to decide do we NIO2 file ops or not. 055 * On Windows the final move needs retry/staging logic, see {@link #retryingMove(Path, Path, StandardCopyOption[])}. 056 */ 057 protected static final boolean IS_WINDOWS = 058 System.getProperty("os.name", "unknown").startsWith("Windows"); 059 060 /** 061 * Escape hatch if atomic move is not desired on system we run on. 062 */ 063 protected static final boolean ATOMIC_MOVE = 064 Boolean.parseBoolean(System.getProperty(PathProcessor.class.getName() + ".ATOMIC_MOVE", "true")); 065 066 /** 067 * The number of attempts for the final move on Windows, where the move target may be transiently locked by a 068 * virus scanner, an indexer or a concurrent reader. 069 */ 070 protected static final int WINDOWS_MOVE_ATTEMPTS = 071 Math.max(1, Integer.getInteger(PathProcessor.class.getName() + ".WINDOWS_MOVE_ATTEMPTS", 5)); 072 073 /** 074 * The delay in milliseconds applied between the move attempts on Windows. 075 */ 076 protected static final long WINDOWS_MOVE_RETRY_DELAY = 077 Long.getLong(PathProcessor.class.getName() + ".WINDOWS_MOVE_RETRY_DELAY", 50L); 078 079 /** 080 * Whether to fall back to a direct (non-atomic) stream copy when all rename attempts on Windows are exhausted 081 * with {@link AccessDeniedException} due to a persistent file lock (for example an IDE holding the target 082 * {@code .jar} open). When {@code true} (the default), the installer degrades gracefully to the pre-2.0.24 083 * behavior — the target is overwritten in-place via a stream, which on Windows succeeds even if another 084 * process holds a shared read handle. The atomicity guarantee is lost for this last-resort path, but the 085 * build succeeds. Set to {@code false} to keep the strict never-truncate contract and let the build fail 086 * with the original {@link AccessDeniedException} instead. 087 */ 088 protected static final boolean WINDOWS_CLASSIC_COPY_FALLBACK = Boolean.parseBoolean( 089 System.getProperty(PathProcessor.class.getName() + ".WINDOWS_CLASSIC_COPY_FALLBACK", "true")); 090 091 @Override 092 public boolean setLastModified(Path path, long value) throws IOException { 093 try { 094 Files.setLastModifiedTime(path, FileTime.fromMillis(value)); 095 return true; 096 } catch (FileSystemException e) { 097 // MRESOLVER-536: Java uses generic FileSystemException for some weird cases, 098 // but some subclasses like AccessDeniedEx should be re-thrown 099 if (e instanceof AccessDeniedException) { 100 throw e; 101 } 102 return false; 103 } 104 } 105 106 @Override 107 public void write(Path target, String data) throws IOException { 108 writeFile(target, p -> Files.write(p, data.getBytes(StandardCharsets.UTF_8)), false); 109 } 110 111 @Override 112 public void write(Path target, InputStream source) throws IOException { 113 writeFile(target, p -> Files.copy(source, p, StandardCopyOption.REPLACE_EXISTING), false); 114 } 115 116 @Override 117 public void writeWithBackup(Path target, String data) throws IOException { 118 writeFile(target, p -> Files.write(p, data.getBytes(StandardCharsets.UTF_8)), true); 119 } 120 121 @Override 122 public void writeWithBackup(Path target, InputStream source) throws IOException { 123 writeFile(target, p -> Files.copy(source, p, StandardCopyOption.REPLACE_EXISTING), true); 124 } 125 126 /** 127 * A file writer, that accepts a {@link Path} to write some content to. Note: the file denoted by path may exist, 128 * hence implementation have to ensure it is able to achieve its goal ("replace existing" option or equivalent 129 * should be used). 130 */ 131 @FunctionalInterface 132 public interface FileWriter { 133 void write(Path path) throws IOException; 134 } 135 136 /** 137 * Utility method to write out file to disk in "atomic" manner, with optional backups (".bak") if needed. This 138 * ensures that no other thread or process will be able to read not fully written files. Finally, this method 139 * may create the needed parent directories, if the passed in target parents does not exist. 140 * 141 * @param target that is the target file (must be an existing or non-existing file, the path must have parent) 142 * @param writer the writer that will accept a {@link Path} to write content to 143 * @param doBackup if {@code true}, and target file is about to be overwritten, a ".bak" file with old contents will 144 * be created/overwritten 145 * @throws IOException if at any step IO problem occurs 146 */ 147 public void writeFile(Path target, FileWriter writer, boolean doBackup) throws IOException { 148 requireNonNull(target, "target is null"); 149 requireNonNull(writer, "writer is null"); 150 Path parent = requireNonNull(target.getParent(), "target must have parent"); 151 152 try (CollocatedTempFile tempFile = newTempFile(target)) { 153 writer.write(tempFile.getPath()); 154 if (doBackup && Files.isRegularFile(target)) { 155 Files.copy(target, parent.resolve(target.getFileName() + ".bak"), StandardCopyOption.REPLACE_EXISTING); 156 } 157 tempFile.move(); 158 } 159 } 160 161 @Override 162 public long copy(Path source, Path target, ProgressListener listener) throws IOException { 163 try (InputStream in = new BufferedInputStream(Files.newInputStream(source)); 164 CollocatedTempFile tempTarget = newTempFile(target); 165 OutputStream out = new BufferedOutputStream(Files.newOutputStream(tempTarget.getPath()))) { 166 long result = copy(out, in, listener); 167 tempTarget.move(); 168 return result; 169 } 170 } 171 172 private long copy(OutputStream os, InputStream is, ProgressListener listener) throws IOException { 173 long total = 0L; 174 byte[] buffer = new byte[1024 * 32]; 175 while (true) { 176 int bytes = is.read(buffer); 177 if (bytes < 0) { 178 break; 179 } 180 181 os.write(buffer, 0, bytes); 182 183 total += bytes; 184 185 if (listener != null && bytes > 0) { 186 try { 187 listener.progressed(ByteBuffer.wrap(buffer, 0, bytes)); 188 } catch (Exception e) { 189 // too bad 190 } 191 } 192 } 193 194 return total; 195 } 196 197 @Override 198 public void move(Path source, Path target) throws IOException { 199 final StandardCopyOption[] copyOption = ATOMIC_MOVE 200 ? new StandardCopyOption[] { 201 StandardCopyOption.ATOMIC_MOVE, 202 StandardCopyOption.REPLACE_EXISTING, 203 StandardCopyOption.COPY_ATTRIBUTES 204 } 205 : new StandardCopyOption[] {StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES}; 206 if (IS_WINDOWS) { 207 retryingMove(source, target, copyOption); 208 } else { 209 Files.move(source, target, copyOption); 210 } 211 Files.deleteIfExists(source); 212 } 213 214 // Temp files 215 216 @Override 217 public TempFile newTempFile() throws IOException { 218 Path tempFile = Files.createTempFile("resolver", "tmp"); 219 return new TempFile() { 220 @Override 221 public Path getPath() { 222 return tempFile; 223 } 224 225 @Override 226 public void close() throws IOException { 227 Files.deleteIfExists(tempFile); 228 } 229 }; 230 } 231 232 @Override 233 public CollocatedTempFile newTempFile(Path file) throws IOException { 234 Path parent = requireNonNull(file.getParent(), "file must have parent"); 235 Files.createDirectories(parent); 236 Path tempFile = parent.resolve(file.getFileName() + "." 237 + Long.toUnsignedString(ThreadLocalRandom.current().nextLong()) + ".tmp"); 238 return new CollocatedTempFile() { 239 private final AtomicBoolean wantsMove = new AtomicBoolean(false); 240 private final StandardCopyOption[] copyOption = ATOMIC_MOVE 241 ? new StandardCopyOption[] {StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING} 242 : new StandardCopyOption[] {StandardCopyOption.REPLACE_EXISTING}; 243 244 @Override 245 public Path getPath() { 246 return tempFile; 247 } 248 249 @Override 250 public void move() { 251 wantsMove.set(true); 252 } 253 254 @Override 255 public void close() throws IOException { 256 if (wantsMove.get()) { 257 if (IS_WINDOWS) { 258 retryingMove(tempFile, file, copyOption); 259 } else { 260 Files.move(tempFile, file, copyOption); 261 } 262 } 263 Files.deleteIfExists(tempFile); 264 } 265 }; 266 } 267 268 /** 269 * Moves the source file to the target path, preferring atomic rename over truncating the target in place. 270 * <p> 271 * Normally the content visible at the target path is either the old file or the complete new file, never a 272 * partially written one. This method attempts the rename a bounded number of times ({@link #WINDOWS_MOVE_ATTEMPTS}; 273 * file locking by virus scanners, indexers or concurrent readers is transient on Windows), and if the file system 274 * refuses an atomic move (for example when source and target are on different stores), the source is first staged 275 * into a collocated temporary file next to the target and then renamed into place. 276 * <p> 277 * <strong>Last-resort fallback (Windows / IDE lock):</strong> When all rename attempts are exhausted with 278 * {@link AccessDeniedException} — typically because an IDE such as IntelliJ keeps the target {@code .jar} open 279 * continuously — and {@link #WINDOWS_CLASSIC_COPY_FALLBACK} is {@code true} (the default), the method falls back 280 * to a direct stream copy over the target path ({@link #classicCopy(Path, Path)}). On Windows, opening a file for 281 * writing via a stream succeeds even if another process holds a shared read handle, so the build completes. The 282 * atomicity guarantee is lost for this edge case: a concurrent reader may observe a partially written file. Set 283 * the system property {@code org.eclipse.aether.spi.io.PathProcessor.WINDOWS_CLASSIC_COPY_FALLBACK=false} to 284 * disable this fallback and propagate the {@link AccessDeniedException} instead. 285 */ 286 protected void retryingMove(Path source, Path target, StandardCopyOption[] copyOptions) throws IOException { 287 FileSystemException lastException = null; 288 for (int attempt = 0; attempt < WINDOWS_MOVE_ATTEMPTS; attempt++) { 289 try { 290 fileSystemMove(source, target, copyOptions); 291 return; 292 } catch (AtomicMoveNotSupportedException e) { 293 lastException = e; 294 break; // retrying will not help; stage a collocated copy instead, see below 295 } catch (FileSystemException e) { 296 // covers AccessDeniedException and sharing violations: transient on Windows 297 lastException = e; 298 try { 299 Thread.sleep(WINDOWS_MOVE_RETRY_DELAY); 300 } catch (InterruptedException ie) { 301 Thread.currentThread().interrupt(); 302 InterruptedIOException interrupted = 303 new InterruptedIOException("Interrupted while moving " + source + " to " + target); 304 interrupted.initCause(ie); 305 interrupted.addSuppressed(e); 306 throw interrupted; 307 } 308 } 309 } 310 if (lastException instanceof AtomicMoveNotSupportedException) { 311 Path staged = target.resolveSibling(target.getFileName() + "." 312 + Long.toUnsignedString(ThreadLocalRandom.current().nextLong()) + ".tmp"); 313 try { 314 classicCopy(source, staged); 315 fileSystemMove(staged, target, copyOptions); 316 return; 317 } finally { 318 Files.deleteIfExists(staged); 319 } 320 } 321 // All rename attempts failed with AccessDeniedException (persistent lock, e.g. IDE holding the jar open). 322 // As a last resort, fall back to a direct stream copy over the target. On Windows this succeeds even when 323 // another process holds a shared read handle because the stream open uses FILE_SHARE_READ semantics, 324 // unlike MoveFileEx/ReplaceFile which require exclusive access. The atomicity guarantee is lost here. 325 if (isClassicCopyFallbackEnabled() && (lastException instanceof AccessDeniedException)) { 326 LOGGER.warn( 327 "Atomic rename of {} to {} failed after {} attempt(s) ({}); falling back to direct copy." 328 + " Another process may be holding the target file open." 329 + " Set -D{}.WINDOWS_CLASSIC_COPY_FALLBACK=false to disable this fallback.", 330 source, 331 target, 332 WINDOWS_MOVE_ATTEMPTS, 333 lastException.getMessage(), 334 PathProcessor.class.getName()); 335 classicCopy(source, target); 336 return; 337 } 338 throw lastException; 339 } 340 341 /** 342 * Testable seam for {@link Files#move(Path, Path, java.nio.file.CopyOption...)}. 343 */ 344 protected void fileSystemMove(Path source, Path target, StandardCopyOption... copyOptions) throws IOException { 345 Files.move(source, target, copyOptions); 346 } 347 348 /** 349 * Returns whether the classic-copy fallback is enabled. Exposed as a protected method to allow subclasses 350 * (and tests) to override the behavior controlled by {@link #WINDOWS_CLASSIC_COPY_FALLBACK}. 351 */ 352 protected boolean isClassicCopyFallbackEnabled() { 353 return WINDOWS_CLASSIC_COPY_FALLBACK; 354 } 355 356 /** 357 * Pre-NIO2 way to copy files. Opens the target with truncation; normally callers stage into a temporary file and 358 * rename into place to avoid exposing partially written content. Used as a last resort on Windows when all atomic 359 * rename attempts are exhausted due to a persistent file lock (see {@link #retryingMove}). 360 */ 361 protected void classicCopy(Path source, Path target) throws IOException { 362 ByteBuffer buffer = ByteBuffer.allocate(1024 * 32); 363 byte[] array = buffer.array(); 364 try (InputStream is = Files.newInputStream(source); 365 OutputStream os = Files.newOutputStream(target)) { 366 while (true) { 367 int bytes = is.read(array); 368 if (bytes < 0) { 369 break; 370 } 371 os.write(array, 0, bytes); 372 } 373 } 374 } 375}