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}