001/*
002 * Copyright 2023 the original author or authors.
003 * <p>
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 * <p>
008 * https://www.apache.org/licenses/LICENSE-2.0
009 * <p>
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016package de.cuioss.tools.io;
017
018import static java.util.Objects.requireNonNull;
019
020import java.io.File;
021import java.io.FileInputStream;
022import java.io.IOException;
023import java.io.InputStream;
024import java.nio.file.Files;
025import java.nio.file.Path;
026import java.nio.file.Paths;
027import java.nio.file.StandardCopyOption;
028import java.text.SimpleDateFormat;
029import java.util.Date;
030
031import de.cuioss.tools.logging.CuiLogger;
032import lombok.NonNull;
033import lombok.experimental.UtilityClass;
034
035/**
036 * Provides {@link Path} related utilities
037 *
038 * @author Oliver Wolff
039 *
040 */
041@UtilityClass
042public final class MorePaths {
043
044    private static final CuiLogger log = new CuiLogger(MorePaths.class);
045
046    /** ".backup" */
047    public static final String BACKUP_DIR_NAME = ".backup";
048
049    /** "File or Directory {} is not accessible, reason: {}". */
050    public static final String MSG_DIRECTORY_NOT_ACCESSIBLE = "File or Directory {} is not accessible, reason: {}";
051
052    /** The prefix to be attached to a backup-file */
053    public static final String BACKUP_FILE_SUFFIX = ".bck_";
054
055    /**
056     * Tries to determine the real-path by calling
057     * {@link Path#toRealPath(java.nio.file.LinkOption...)} with no further
058     * parameter passed. In case the real path can not be resolved it will LOG at
059     * warn-level and return {@link Path#toAbsolutePath()}.
060     *
061     * @param path must not be null
062     * @return the real-path if applicable, {@link Path#toAbsolutePath()} otherwise.
063     */
064    public static Path getRealPathSafely(Path path) {
065        requireNonNull(path, "Path must not be null");
066        try {
067            return path.toRealPath();
068        } catch (IOException e) {
069            log.warn("Unable to resolve real path for '{}', due to '{}'. Returning absolutePath.", path, e.getMessage(),
070                    e);
071            return path.toAbsolutePath();
072        }
073    }
074
075    /**
076     * Tries to determine the real-path, see {@link #getRealPathSafely(Path)} for
077     * details and {@link Paths#get(String, String...)} for details regarding the
078     * parameter
079     *
080     * @param first the path string or initial part of the path string
081     * @param more  additional strings to be joined to form the path string
082     * @return the real-path if applicable, {@link Path#toAbsolutePath()} otherwise.
083     */
084    public static Path getRealPathSafely(String first, String... more) {
085        return getRealPathSafely(Paths.get(first, more));
086    }
087
088    /**
089     * Tries to determine the real-path, see {@link #getRealPathSafely(Path)} for
090     * details
091     *
092     * @param file the {@link Path} to be looked up
093     *
094     * @return the real-path if applicable, {@link Path#toAbsolutePath()} otherwise.
095     */
096    public static Path getRealPathSafely(File file) {
097        requireNonNull(file, "File must not be null");
098        return getRealPathSafely(file.toPath());
099    }
100
101    /**
102     * Checks whether the given {@link Path} denotes an existing read and writable
103     * directory or file.
104     *
105     * @param path              to checked, must not be null
106     * @param checkForDirectory check whether it is a file or directory
107     * @param verbose           indicates whether to log errors at warn-level
108     *
109     * @return boolean indicating whether the given {@link Path} denotes an existing
110     *         read and writable directory.
111     */
112    public static boolean checkAccessiblePath(final @NonNull Path path, final boolean checkForDirectory,
113            final boolean verbose) {
114        if (!checkReadablePath(path, checkForDirectory, verbose)) {
115            return false;
116        }
117        final var pathFile = path.toFile();
118        final var absolutePath = pathFile.getAbsolutePath();
119        if (!pathFile.canWrite()) {
120            if (verbose) {
121                log.warn(MSG_DIRECTORY_NOT_ACCESSIBLE, absolutePath, "Not Writable");
122            }
123            return false;
124        }
125        log.debug("{} denotes an existing file / directory with read and write permissions", absolutePath);
126        return true;
127    }
128
129    /**
130     * Checks whether the given {@link Path} denotes an existing readable directory
131     * or file.
132     *
133     * @param path              to checked, must not be null
134     * @param checkForDirectory check whether it is a file or directory
135     * @param verbose           indicates whether to log errors at warn-level
136     *
137     * @return boolean indicating whether the given {@link Path} denotes an existing
138     *         readable directory.
139     */
140    public static boolean checkReadablePath(final @NonNull Path path, final boolean checkForDirectory,
141            final boolean verbose) {
142        final var pathFile = path.toFile();
143        final var absolutePath = pathFile.getAbsolutePath();
144        if (!pathFile.exists()) {
145            if (verbose) {
146                log.warn(MSG_DIRECTORY_NOT_ACCESSIBLE, absolutePath, "Not Existing");
147            }
148            return false;
149        }
150        if (checkForDirectory) {
151            if (!pathFile.isDirectory()) {
152                if (verbose) {
153                    log.warn(MSG_DIRECTORY_NOT_ACCESSIBLE, absolutePath, "Not a directory");
154                }
155                return false;
156            }
157        } else if (!pathFile.isFile()) {
158            if (verbose) {
159                log.warn(MSG_DIRECTORY_NOT_ACCESSIBLE, absolutePath, "Not a file");
160            }
161            return false;
162        }
163        if (!pathFile.canRead()) {
164            if (verbose) {
165                log.warn(MSG_DIRECTORY_NOT_ACCESSIBLE, absolutePath, "Not Readable");
166            }
167            return false;
168        }
169        log.debug("{} denotes an existing file / directory with read permissions", absolutePath);
170        return true;
171    }
172
173    /**
174     * Checks whether the given {@link Path} denotes an existing executable file.
175     *
176     * @param path    to checked, must not be null
177     * @param verbose indicates whether to log errors at warn-level
178     *
179     * @return boolean indicating whether the given {@link Path} denotes an existing
180     *         readable directory.
181     */
182    public static boolean checkExecutablePath(final @NonNull Path path, final boolean verbose) {
183        final var pathFile = path.toFile();
184        final var absolutePath = pathFile.getAbsolutePath();
185        if (!pathFile.exists()) {
186            if (verbose) {
187                log.warn(MSG_DIRECTORY_NOT_ACCESSIBLE, absolutePath, "Not Existing");
188            }
189            return false;
190        }
191        if (!pathFile.isFile()) {
192            if (verbose) {
193                log.warn(MSG_DIRECTORY_NOT_ACCESSIBLE, absolutePath, "Not a file");
194            }
195            return false;
196        }
197        if (!pathFile.canExecute()) {
198            if (verbose) {
199                log.warn(MSG_DIRECTORY_NOT_ACCESSIBLE, absolutePath, "Not Executable");
200            }
201            return false;
202        }
203        log.debug("{} denotes an existing file / directory with execute permission", absolutePath);
204        return true;
205    }
206
207    /**
208     * Creates / or references a backup-directory named ".backup" within the given
209     * directory and returns it
210     *
211     * @param directory must not null and denote an existing writable directory,
212     *                  otherwise am {@link IllegalArgumentException} will be
213     *                  thrown.
214     * @return the ".backup" directory
215     */
216    public static Path getBackupDirectoryForPath(final Path directory) {
217        if (!checkAccessiblePath(directory, true, true)) {
218            throw new IllegalArgumentException("Given path '%s' does not denote an existing writable directory"
219                    .formatted(directory.toFile().getAbsolutePath()));
220        }
221        final var backup = directory.resolve(BACKUP_DIR_NAME);
222        final var backupAsFile = backup.toFile();
223        if (!backupAsFile.exists() && !backupAsFile.mkdir()) {
224            throw new IllegalStateException(
225                    "Unable to create directory '%s'".formatted(backup.toFile().getAbsolutePath()));
226        }
227        return backup;
228
229    }
230
231    /**
232     * Backups the file, identified by the given path into the backup directory,
233     * derived with {@link #getBackupDirectoryForPath(Path)}. The original file
234     * attributes will be applied to the copied filed, See
235     * {@link StandardCopyOption#COPY_ATTRIBUTES}.
236     *
237     * @param path must not be null and denote an existing read and writable file
238     * @return Path on the newly created file
239     * @throws IOException if an I/O error occurs
240     */
241    public static Path backupFile(final Path path) throws IOException {
242        assertAccessibleFile(path);
243        var backupDir = getBackupDirectoryForPath(path.getParent());
244
245        var backupFile = createNonExistingPath(backupDir,
246                path.getFileName() + BACKUP_FILE_SUFFIX + new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()));
247
248        Files.copy(path, backupFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
249        log.debug("Created backup from '{}' at '{}'", path.toFile().getAbsolutePath(),
250                backupFile.toFile().getAbsolutePath());
251        return backupFile;
252    }
253
254    /**
255     * Creates a temp-copy of the given file, identified by the given path. The
256     * original file attributes will be applied to the copied filed, See
257     * {@link StandardCopyOption#COPY_ATTRIBUTES}.
258     * <h2>Caution: Security-Impact</h2> Creating a temp-file might introduce a
259     * security issue. Never ever use this location for sensitive information that
260     * might be of interest for an attacker
261     *
262     * @param path must not be null and denote an existing read and writable file
263     * @return Path on the newly created file
264     * @throws IOException if an I/O error occurs
265     */
266    @SuppressWarnings("java:S5443") // owolff: See hint Caution: Security-Impact
267    public static Path copyToTempLocation(final Path path) throws IOException {
268        assertAccessibleFile(path);
269        var filename = new StructuredFilename(path.getFileName());
270        var tempFile = Files.createTempFile(filename.getNamePart(), filename.getSuffix());
271
272        Files.copy(path, tempFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.COPY_ATTRIBUTES);
273        log.debug("Created temp-file from '{}' at '{}'", path.toFile().getAbsolutePath(),
274                tempFile.toFile().getAbsolutePath());
275        return tempFile;
276    }
277
278    /**
279     * Asserts whether a given {@link Path} is accessible, saying it exits as a file
280     * and is read and writable. If not it will throw an
281     * {@link IllegalArgumentException}
282     *
283     * @param path to be checked, must not be null
284     */
285    public static void assertAccessibleFile(final Path path) {
286        requireNonNull(path, "path");
287        if (!checkAccessiblePath(path, false, true)) {
288            throw new IllegalArgumentException("Given path '%s' does not denote an existing readable file"
289                    .formatted(path.toFile().getAbsolutePath()));
290        }
291    }
292
293    static Path createNonExistingPath(final Path parentDir, final String fileName) {
294        var backupFile = parentDir.resolve(fileName);
295        if (!backupFile.toFile().exists()) {
296            return backupFile;
297        }
298
299        for (var counter = 1; counter < 20; counter++) {
300            var newName = fileName + "_" + counter;
301            var newBackupFile = parentDir.resolve(newName);
302            if (!newBackupFile.toFile().exists()) {
303                return newBackupFile;
304            }
305        }
306        throw new IllegalStateException("Unable to determine a non-existing file within '%s' for file-name '%s'"
307                .formatted(parentDir.toFile().getAbsolutePath(), fileName));
308    }
309
310    /**
311     * Deletes a file, never throwing an exception. If file is a directory, delete
312     * it and all subdirectories. Inspired by
313     * org.apache.commons.io.FileUtils#deleteQuietly
314     * <p>
315     * The difference between File.delete() and this method are:
316     * <ul>
317     * <li>A directory to be deleted does not have to be empty.</li>
318     * <li>No exceptions are thrown when a file or directory cannot be deleted.</li>
319     * </ul>
320     *
321     * @param path file or directory to delete, can be {@code null}
322     * @return {@code true} if the file or directory was deleted, otherwise
323     *         {@code false}
324     */
325    public static boolean deleteQuietly(final Path path) {
326        log.trace("Deleting file {}", path);
327        if (path == null) {
328            return false;
329        }
330        var file = path.toFile();
331        final var absolutePath = file.getAbsolutePath();
332        if (!file.exists()) {
333            log.trace("Path {} does not exist", absolutePath);
334            return false;
335        }
336        var recursiveSucceful = true;
337        try {
338            if (file.isDirectory()) {
339                log.trace("Path {} is directory, checking children", absolutePath);
340                for (String child : file.list()) {
341                    if (!deleteQuietly(path.resolve(child))) {
342                        recursiveSucceful = false;
343                    }
344
345                }
346            }
347        } catch (final Exception e) {
348            log.trace(e, "Unable to check Path {} whether it is a directory", absolutePath);
349        }
350
351        try {
352            if (Files.deleteIfExists(path)) {
353                log.trace("Successully deleted path {}", absolutePath);
354            } else {
355                recursiveSucceful = false;
356            }
357        } catch (final Exception e) {
358            log.trace(e, "Unable to delete Path {}", absolutePath);
359            return false;
360        }
361        return recursiveSucceful;
362    }
363
364    /**
365     *
366     * Compares the contents of two files to determine if they are equal or not.
367     * <p>
368     * This method checks to see if the two files are different lengths or if they
369     * point to the same file, before resorting to byte-by-byte comparison of the
370     * contents.
371     * <p>
372     * Taken from org.apache.commons.io.FileUtils.contentEquals(File, File) Code
373     * origin: Avalon
374     *
375     * @param path1 the first file
376     * @param path2 the second file
377     * @return true if the content of the files are equal or they both don't exist,
378     *         false otherwise
379     * @throws IOException in case of an I/O error
380     */
381    public static boolean contentEquals(final Path path1, final Path path2) throws IOException {
382        requireNonNull(path1);
383        requireNonNull(path2);
384        var file1 = path1.toFile();
385        var file2 = path2.toFile();
386        final var file1Exists = file1.exists();
387        if (file1Exists != file2.exists()) {
388            return false;
389        }
390
391        if (!file1Exists) {
392            // two not existing files are equal
393            return true;
394        }
395
396        if (file1.isDirectory() || file2.isDirectory()) {
397            // don't want to compare directory contents
398            throw new IOException("Can't compare directories, only files");
399        }
400
401        if (file1.length() != file2.length()) {
402            // lengths differ, cannot be equal
403            return false;
404        }
405
406        if (file1.getCanonicalFile().equals(file2.getCanonicalFile())) {
407            // same file
408            return true;
409        }
410
411        try (InputStream input1 = new FileInputStream(file1); InputStream input2 = new FileInputStream(file2)) {
412            return IOStreams.contentEquals(input1, input2);
413        }
414    }
415
416    /**
417     * Command pattern interface delegating the file write operation to its caller.
418     *
419     * @author Sven Haag
420     *
421     */
422    public interface FileWriteHandler {
423
424        /**
425         * @param filePath where the write operation should take place on.
426         * @throws IOException if an I/O error occurs
427         */
428        void write(final Path filePath) throws IOException;
429    }
430
431    /**
432     * Save a file by maintaining all its attributes and permissions. Also creates a
433     * backup, see {@linkplain #backupFile(Path)}.
434     *
435     * <h1>Usage</h1>
436     * <p>
437     * PathUtils.saveAndBackup(myOriginalFilePath, targetPath ->
438     * JdomHelper.writeJdomToFile(document, targetPath));
439     * </p>
440     *
441     * @param filePath         path to the original / target file
442     * @param fileWriteHandler do your write operation to the given file path
443     *                         provided by
444     *                         {@linkplain FileWriteHandler#write(Path)}.
445     * @throws IOException if an I/O error occurs
446     */
447    public static void saveAndBackup(final Path filePath, final FileWriteHandler fileWriteHandler) throws IOException {
448        // Copy original file to temp
449        final var temp = copyToTempLocation(filePath);
450
451        // Save data to temp file
452        fileWriteHandler.write(temp);
453
454        // Create backup from original
455        backupFile(filePath);
456
457        // Replace original with temp file
458        java.nio.file.Files.copy(temp, filePath, StandardCopyOption.REPLACE_EXISTING,
459                StandardCopyOption.COPY_ATTRIBUTES);
460    }
461
462    /**
463     * Checks, if the two given paths are pointing to the same location.
464     * <p>
465     * If both paths are not {@code null} and do {@link File#exists()}, the
466     * {@link Files#isSameFile(Path, Path)} method is used to check if both paths
467     * are pointing to the same location. Otherwise, if one of the paths does not
468     * exist, the {@link Paths#equals(Object)} method is used.
469     *
470     * @param path  to be compared with path2
471     * @param path2 to be compared with path
472     *
473     * @return {@code true}, if both paths are {@code null}. {@code true}, if both
474     *         paths not {@code null}, do exist, and
475     *         {@link Files#isSameFile(Path, Path)}. {@code true}, if both paths not
476     *         {@code null} and {@link Paths#equals(Object)} {@code false}
477     *         otherwise.
478     */
479    public static boolean isSameFile(Path path, Path path2) {
480        if (null == path && null == path2) {
481            return true;
482        }
483
484        if (null != path && null != path2) {
485            if (!path.toFile().exists() || !path2.toFile().exists()) {
486                log.debug("""
487                        Comparing paths with #equals, as at least one path does not exist. \
488                        path_a={}, path_b={}\
489                        """, path, path2);
490                return path.equals(path2);
491            }
492            try {
493                return Files.isSameFile(path, path2);
494            } catch (final IOException e) {
495                log.error(e, "Portal-123: Unable to compare path_a={} and path_b={}", path, path2);
496            }
497        } else {
498            log.trace("at least one path is null: path_a={}, path_b={}", path, path2);
499        }
500
501        return false;
502    }
503}