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 static java.util.Objects.requireNonNull;
040
041/**
042 * Utility class serving as base of {@link PathProcessor} implementations. This class can be extended or replaced
043 * (as component) when needed. Also, this class is published in Resolver implementation for path processor interface.
044 *
045 * @since 2.0.13
046 */
047public class PathProcessorSupport implements PathProcessor {
048    /**
049     * Logic borrowed from Commons-Lang3: we really need only this, to decide do we NIO2 file ops or not.
050     * On Windows the final move needs retry/staging logic, see {@link #retryingMove(Path, Path, StandardCopyOption[])}.
051     */
052    protected static final boolean IS_WINDOWS =
053            System.getProperty("os.name", "unknown").startsWith("Windows");
054
055    /**
056     * Escape hatch if atomic move is not desired on system we run on.
057     */
058    protected static final boolean ATOMIC_MOVE =
059            Boolean.parseBoolean(System.getProperty(PathProcessor.class.getName() + "ATOMIC_MOVE", "true"));
060
061    /**
062     * The number of attempts for the final move on Windows, where the move target may be transiently locked by a
063     * virus scanner, an indexer or a concurrent reader.
064     */
065    protected static final int WINDOWS_MOVE_ATTEMPTS =
066            Math.max(1, Integer.getInteger(PathProcessor.class.getName() + "WINDOWS_MOVE_ATTEMPTS", 5));
067
068    /**
069     * The delay in milliseconds applied between the move attempts on Windows.
070     */
071    protected static final long WINDOWS_MOVE_RETRY_DELAY =
072            Long.getLong(PathProcessor.class.getName() + "WINDOWS_MOVE_RETRY_DELAY", 50L);
073
074    @Override
075    public boolean setLastModified(Path path, long value) throws IOException {
076        try {
077            Files.setLastModifiedTime(path, FileTime.fromMillis(value));
078            return true;
079        } catch (FileSystemException e) {
080            // MRESOLVER-536: Java uses generic FileSystemException for some weird cases,
081            // but some subclasses like AccessDeniedEx should be re-thrown
082            if (e instanceof AccessDeniedException) {
083                throw e;
084            }
085            return false;
086        }
087    }
088
089    @Override
090    public void write(Path target, String data) throws IOException {
091        writeFile(target, p -> Files.write(p, data.getBytes(StandardCharsets.UTF_8)), false);
092    }
093
094    @Override
095    public void write(Path target, InputStream source) throws IOException {
096        writeFile(target, p -> Files.copy(source, p, StandardCopyOption.REPLACE_EXISTING), false);
097    }
098
099    @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}