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}