001/* 002 * Licensed to the Apache Software Foundation (ASF) under one or more 003 * contributor license agreements. See the NOTICE file distributed with 004 * this work for additional information regarding copyright ownership. 005 * The ASF licenses this file to You under the Apache License, Version 2.0 006 * (the "License"); you may not use this file except in compliance with 007 * the License. You may obtain a copy of the License at 008 * 009 * http://www.apache.org/licenses/LICENSE-2.0 010 * 011 * Unless required by applicable law or agreed to in writing, software 012 * distributed under the License is distributed on an "AS IS" BASIS, 013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 014 * See the License for the specific language governing permissions and 015 * limitations under the License. 016 */ 017package org.apache.juneau.commons.utils; 018 019import static org.apache.juneau.commons.utils.Exceptions.*; 020import static org.apache.juneau.commons.utils.IoUtils.*; 021import static org.apache.juneau.commons.utils.ObjectUtils.*; 022import static org.apache.juneau.commons.utils.Shorts.eq; 023import static org.apache.juneau.commons.utils.Shorts.reqnn; 024import static org.apache.juneau.commons.utils.StringUtils.*; 025import static org.apache.juneau.commons.utils.StringUtils.isEmpty; 026import static org.apache.juneau.commons.utils.ThrowableUtils.*; 027 028import java.io.*; 029import java.nio.file.*; 030import java.util.*; 031 032/** 033 * File utilities. 034 * 035 */ 036@SuppressWarnings({ 037 "java:S115", // Constants use UPPER_snakeCase convention 038 "java:S1192" // Duplicated literals (argument/property names) read more clearly inline than as constants 039}) 040public class FileUtils { 041 042 /** 043 * Prevents instantiation. 044 */ 045 private FileUtils() {} 046 047 // Shared message text — kept identical across the two boundary-check sites so attackers 048 // cannot distinguish "rejected by pre-existence check" from "rejected by symlink check". 049 private static final String MSG_pathEscape = "Path escapes configured root directory."; 050 051 /** 052 * Creates a file if it doesn't already exist using {@link File#createNewFile()}. 053 * 054 * <p> 055 * Throws a {@link RuntimeException} if the file could not be created. 056 * 057 * @param f The file to create. Must not be <jk>null</jk>. 058 */ 059 public static void create(File f) { 060 if (f.exists()) 061 return; 062 safe(() -> optional(f.createNewFile()).filter(x -> x).orElseThrow(() -> rex("Could not create file '%s'", f.getAbsolutePath()))); 063 } 064 065 /** 066 * Create a temporary file with the specified name. 067 * 068 * <p> 069 * The name is broken into file name and suffix, and the parts are passed to 070 * {@link File#createTempFile(String, String)}. 071 * 072 * <p> 073 * {@link File#deleteOnExit()} is called on the resulting file before being returned by this method. 074 * 075 * @param name The file name. Must not be <jk>null</jk> (unguarded — a <jk>null</jk> name throws {@link NullPointerException}). 076 * @return A newly-created temporary file. 077 * @throws IOException Thrown by underlying stream. 078 */ 079 @SuppressWarnings({ 080 "java:S5443" // Controlled internal helper: uses JDK default temp dir, deleteOnExit; explicit POSIX perms aren't portable to Windows. 081 }) 082 public static File createTempFile(String name) throws IOException { 083 var parts = name.split("\\."); 084 var f = File.createTempFile(parts[0], "." + parts[1]); 085 f.deleteOnExit(); 086 return f; 087 } 088 089 /** 090 * Create a temporary file with the specified name and specified contents. 091 * 092 * <p> 093 * The name is broken into file name and suffix, and the parts are passed to 094 * {@link File#createTempFile(String, String)}. 095 * 096 * <p> 097 * {@link File#deleteOnExit()} is called on the resulting file before being returned by this method. 098 * 099 * @param name The file name. Must not be <jk>null</jk> (unguarded — a <jk>null</jk> name throws {@link NullPointerException}). 100 * @param contents The file contents. Can be <jk>null</jk> (creates an empty file). 101 * @return A newly-created temporary file. 102 * @throws IOException Thrown by underlying stream. 103 */ 104 public static File createTempFile(String name, String contents) throws IOException { 105 var f = createTempFile(name); 106 if (contents != null) { 107 try (var r = new StringReader(contents); Writer w = new FileWriter(f)) { 108 pipe(r, w); 109 w.flush(); 110 } 111 } 112 // If contents is null, create an empty file 113 return f; 114 } 115 116 /** 117 * Recursively deletes a file or directory. 118 * 119 * @param f The file or directory to delete. Can be <jk>null</jk> (returns <jk>true</jk>). 120 * @return <jk>true</jk> if file or directory was successfully deleted. 121 */ 122 public static boolean deleteFile(File f) { 123 if (f == null) 124 return true; 125 if (f.isDirectory()) { 126 var cf = f.listFiles(); 127 if (isNotNull(cf)) 128 for (var c : cf) 129 deleteFile(c); 130 } 131 try { 132 Files.delete(f.toPath()); 133 return true; 134 } catch (@SuppressWarnings("unused") IOException e) { 135 return false; 136 } 137 } 138 139 /** 140 * Returns <jk>true</jk> if the specified file exists in the specified directory. 141 * 142 * @param dir The directory. Can be <jk>null</jk> (returns <jk>false</jk>). 143 * @param fileName The file name. Can be <jk>null</jk> (returns <jk>false</jk>). 144 * @return <jk>true</jk> if the specified file exists in the specified directory. 145 */ 146 public static boolean fileExists(File dir, String fileName) { 147 if (dir == null || fileName == null) 148 return false; 149 return Files.exists(dir.toPath().resolve(fileName)); 150 } 151 152 /** 153 * Strips the extension from a file name. 154 * 155 * @param name The file name. 156 * @return The file name without the extension, or <jk>null</jk> if name was <jk>null</jk>. 157 */ 158 public static String getBaseName(String name) { 159 if (name == null) 160 return null; 161 var i = name.lastIndexOf('.'); 162 if (i == -1) 163 return name; 164 return name.substring(0, i); 165 } 166 167 /** 168 * Returns the extension from a file name. 169 * 170 * @param name The file name. 171 * @return The the extension, or <jk>null</jk> if name was <jk>null</jk>. 172 */ 173 public static String getFileExtension(String name) { 174 if (name == null) 175 return null; 176 var i = name.lastIndexOf('.'); 177 if (i == -1) 178 return ""; 179 return name.substring(i + 1); 180 } 181 182 /** 183 * Given an arbitrary path, returns the file name portion of that path. 184 * 185 * @param path The path to check. Can be <jk>null</jk> or empty (returns <jk>null</jk>). 186 * @return The file name, or <jk>null</jk> if the path was <jk>null</jk>, empty, or contained only slashes. 187 */ 188 public static String getFileName(String path) { 189 if (isEmpty(path)) 190 return null; 191 path = trimTrailingSlashes(path); 192 if (isEmpty(path)) 193 return null; // Path contained only slashes 194 // Handle both forward slashes (Unix) and backslashes (Windows) 195 var i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); 196 return i == -1 ? path : path.substring(i + 1); 197 } 198 199 /** 200 * Returns <jk>true</jk> if the specified file name contains the specified extension. 201 * 202 * @param name The file name. Can be <jk>null</jk> (returns <jk>false</jk>). 203 * @param ext The extension. Can be <jk>null</jk> (returns <jk>false</jk>). 204 * @return <jk>true</jk> if the specified file name contains the specified extension. 205 */ 206 public static boolean hasExtension(String name, String ext) { 207 if (name == null || ext == null) 208 return false; 209 return eq(ext, getFileExtension(name)); 210 } 211 212 /** 213 * Same as {@link File#mkdirs()} except throws a RuntimeExeption if directory could not be created. 214 * 215 * @param f The directory to create. Must not be <jk>null</jk>. 216 * @param clean If <jk>true</jk>, deletes the contents of the directory if it already exists. 217 * @return The same file. 218 * @throws RuntimeException if directory could not be created. 219 */ 220 public static File mkdirs(File f, boolean clean) { 221 reqnn("f", f); 222 if (f.exists()) { 223 if (clean) { 224 optional(deleteFile(f)).filter(x -> x).orElseThrow(() -> rex("Could not clean directory '%s'", f.getAbsolutePath())); 225 } else { 226 return f; 227 } 228 } 229 optional(f.mkdirs()).filter(x -> x).orElseThrow(() -> rex("Could not create directory '%s'", f.getAbsolutePath())); 230 return f; 231 } 232 233 /** 234 * Same as {@link #mkdirs(String, boolean)} but uses String path. 235 * 236 * @param path The path of the directory to create. Must not be <jk>null</jk> 237 * @param clean If <jk>true</jk>, deletes the contents of the directory if it already exists. 238 * @return The directory. 239 */ 240 public static File mkdirs(String path, boolean clean) { 241 reqnn("path", path); 242 return mkdirs(new File(path), clean); 243 } 244 245 /** 246 * Resolves a user-supplied path string against a root directory with a strict 247 * filesystem-boundary check — rejects any path that escapes the root (via 248 * {@code ../}, absolute-path injection, symlinks pointing outside, etc.). 249 * 250 * <p> 251 * Resolves both {@code rootDir} and the resolved target via 252 * {@link Path#toRealPath Path.toRealPath()} + {@link Path#normalize Path.normalize()} 253 * to handle symlinks deterministically, then asserts {@code target.startsWith(root)} 254 * before returning the resolved {@link File}. 255 * 256 * <p> 257 * Symlinks <i>inside</i> {@code rootDir} that resolve <i>inside</i> {@code rootDir} are 258 * followed silently. Symlinks that resolve <i>outside</i> {@code rootDir} are rejected with 259 * {@link IllegalArgumentException} (callers should map to a {@code 403 Forbidden}). 260 * 261 * <p> 262 * Pre-existence semantics: when the resolved target does not exist on the filesystem, the 263 * helper returns {@link Optional#empty()} so the caller can map to a {@code 404 Not Found}. 264 * Boundary violation always wins over pre-existence — even a non-existent path that 265 * escapes {@code rootDir} (after {@code ../} normalization) is rejected with 266 * {@link IllegalArgumentException} rather than {@code Optional.empty()}. 267 * 268 * <p> 269 * Used by {@code DirectoryResource}, {@code LogsResource}, and any other resource that 270 * resolves an HTTP-supplied path under a configured root directory. 271 * 272 * @param rootDir The configured root directory. Must not be <jk>null</jk>. 273 * @param userPath The user-supplied path, relative to {@code rootDir}. May be 274 * <jk>null</jk> or empty — in which case the helper returns the resolved root 275 * itself. 276 * @return An {@link Optional} containing the resolved {@link File} if it exists inside 277 * {@code rootDir}, or {@link Optional#empty()} if the target does not exist. 278 * @throws IllegalArgumentException If the resolved target escapes {@code rootDir}, or if 279 * the supplied {@code userPath} is not a valid path string. Callers should map to 280 * {@code 403 Forbidden}. 281 */ 282 public static Optional<File> resolveSafely(File rootDir, String userPath) { 283 reqnn("rootDir", rootDir); 284 var root = canonicalizeRoot(rootDir.toPath()); 285 if (isEmpty(userPath)) 286 return optional(root.toFile()); 287 Path target; 288 try { 289 target = root.resolve(userPath).normalize(); 290 } catch (@SuppressWarnings("unused") InvalidPathException e) { 291 throw iaex(MSG_pathEscape); 292 } 293 if (! target.startsWith(root)) 294 throw iaex(MSG_pathEscape); 295 var f = target.toFile(); 296 if (! f.exists()) 297 return emptyOptional(); 298 try { 299 if (! target.toRealPath().startsWith(root)) 300 throw iaex(MSG_pathEscape); 301 } catch (@SuppressWarnings("unused") NoSuchFileException e) { 302 return emptyOptional(); 303 } catch (IOException e) { 304 throw rex(e, "Could not canonicalize '%s'", target); 305 } 306 return optional(f); 307 } 308 309 /** 310 * Virtual-path variant of {@link #resolveSafely(File, String)} for use with servlet-context 311 * paths, classpath resource lookups, or other virtual-path resolvers where no filesystem 312 * operation is desired. 313 * 314 * <p> 315 * Operates purely on the virtual path string — no {@code toRealPath()} call, no 316 * filesystem touch, no symlink resolution. Safe to use against base paths whose resources 317 * do not exist on the local filesystem at all (e.g. {@code /WEB-INF/views/} inside a 318 * Spring Boot fat jar, or a classpath URL). 319 * 320 * <p> 321 * Normalizes the combined path by walking segments and applying {@code ./} (drop) and 322 * {@code ../} (pop) semantics, then asserts the result stays under the normalized 323 * {@code basePath}. {@code ../} segments that would pop above {@code basePath} are 324 * rejected. 325 * 326 * <p> 327 * URL-encoding is the caller's responsibility — this helper treats the literal 328 * {@code %2e%2e} as a normal path segment (not as {@code ..}). Callers that receive 329 * user input from URLs should URL-decode before calling. 330 * 331 * @param basePath The configured base path (virtual; e.g. {@code "/WEB-INF/views/"}). 332 * Must not be <jk>null</jk> or blank. 333 * @param userPath The user-supplied path, relative to {@code basePath}. May be 334 * <jk>null</jk> or empty — in which case the helper returns the normalized 335 * {@code basePath}. 336 * @return The resolved virtual path string (always starting with {@code basePath}). 337 * @throws IllegalArgumentException If the resolved target escapes {@code basePath}, or if 338 * {@code basePath} is <jk>null</jk> or blank. Callers should map to 339 * {@code 403 Forbidden}. 340 */ 341 public static String resolveVirtualPathSafely(String basePath, String userPath) { 342 reqnn("basePath", basePath); 343 if (basePath.isBlank()) 344 throw iaex("basePath must not be blank."); 345 var bp = normalizeVirtualPath(basePath); 346 if (bp == null) 347 throw iaex("basePath escapes its own root."); 348 if (! bp.endsWith("/")) 349 bp = bp + "/"; 350 if (isEmpty(userPath)) 351 return bp; 352 var up = userPath.startsWith("/") ? userPath.substring(1) : userPath; 353 var combined = normalizeVirtualPath(bp + up); 354 if (combined == null || ! combined.startsWith(bp)) 355 throw iaex(MSG_pathEscape); 356 return combined; 357 } 358 359 /** 360 * Resolves {@code rootDir} via {@link Path#toRealPath()} so symlinks at the root itself 361 * are normalized once per call. Falls back to {@link Path#toAbsolutePath()} + 362 * {@link Path#normalize()} when the directory does not exist at the moment of the call 363 * (rare; legacy contract tolerated this). 364 */ 365 private static Path canonicalizeRoot(Path rootDir) { 366 try { 367 return rootDir.toRealPath(); 368 } catch (@SuppressWarnings("unused") IOException e) { 369 return rootDir.toAbsolutePath().normalize(); 370 } 371 } 372 373 /** 374 * String-level path normalization for forward-slash separated virtual paths. 375 * Returns {@code null} if a {@code ..} segment would pop above the root. 376 */ 377 @SuppressWarnings({ 378 "java:S135", // Multiple continue statements are intrinsic to this path-segment scan/normalization loop. 379 "java:S3776" // Cognitive complexity: small loop with three early-exit cases; splitting hurts readability. 380 }) 381 private static String normalizeVirtualPath(String path) { 382 if (path == null) 383 return null; 384 var startsWithSlash = path.startsWith("/"); 385 var endsWithSlash = path.length() > 1 && path.endsWith("/"); 386 var segments = new ArrayList<String>(); 387 for (var s : path.split("/")) { 388 if (s.isEmpty() || eq(s, ".")) 389 continue; 390 if (eq(s, "..")) { 391 if (segments.isEmpty()) 392 return null; 393 segments.remove(segments.size() - 1); 394 continue; 395 } 396 segments.add(s); 397 } 398 var sb = new StringBuilder(); 399 if (startsWithSlash) 400 sb.append('/'); 401 sb.append(String.join("/", segments)); 402 if (endsWithSlash && (sb.isEmpty() || sb.charAt(sb.length() - 1) != '/')) 403 sb.append('/'); 404 return sb.toString(); 405 } 406 407 /** 408 * Updates the modified timestamp on the specified file. 409 * 410 * <p> 411 * Method ensures that the timestamp changes even if it's been modified within the past millisecond. 412 * 413 * @param f The file to modify the modified timestamp on. Must not be <jk>null</jk>. 414 */ 415 public static void modifyTimestamp(File f) { 416 var lm = f.lastModified(); 417 var l = System.currentTimeMillis(); 418 if (lm == l) 419 l++; 420 optional(f.setLastModified(l)).filter(x -> x).orElseThrow(() -> rex("Could not modify timestamp on file '%s'", f.getAbsolutePath())); 421 422 // Linux only gives 1s precision, so set the date 1s into the future. 423 if (lm == f.lastModified()) 424 optional(f.setLastModified(l + 1000)).filter(x -> x).orElseThrow(() -> rex("Could not modify timestamp on file '%s'", f.getAbsolutePath())); 425 } 426}