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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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 &mdash; 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}