001package jmri.util;
002
003import static jmri.util.FileUtil.HOME;
004import static jmri.util.FileUtil.PREFERENCES;
005import static jmri.util.FileUtil.PROFILE;
006import static jmri.util.FileUtil.PROGRAM;
007import static jmri.util.FileUtil.SCRIPTS;
008import static jmri.util.FileUtil.SEPARATOR;
009import static jmri.util.FileUtil.SETTINGS;
010
011import com.sun.jna.platform.win32.KnownFolders;
012import com.sun.jna.platform.win32.Shell32Util;
013import com.sun.jna.platform.win32.ShlObj;
014import java.io.BufferedReader;
015import java.io.File;
016import java.io.FileNotFoundException;
017import java.io.FileOutputStream;
018import java.io.IOException;
019import java.io.InputStream;
020import java.io.InputStreamReader;
021import java.io.OutputStreamWriter;
022import java.io.PrintWriter;
023import java.net.MalformedURLException;
024import java.net.URI;
025import java.net.URISyntaxException;
026import java.net.URL;
027import java.nio.charset.StandardCharsets;
028import java.nio.file.FileVisitResult;
029import java.nio.file.Files;
030import java.nio.file.Path;
031import java.nio.file.SimpleFileVisitor;
032import java.nio.file.StandardCopyOption;
033import java.nio.file.attribute.BasicFileAttributes;
034import java.security.CodeSource;
035import java.util.Arrays;
036import java.util.HashMap;
037import java.util.HashSet;
038import java.util.Objects;
039import java.util.Set;
040import java.util.jar.JarFile;
041import java.util.regex.Matcher;
042import java.util.stream.Collectors;
043import javax.annotation.CheckReturnValue;
044import javax.annotation.CheckForNull;
045import javax.annotation.Nonnull;
046
047import jmri.Version;
048import jmri.beans.Bean;
049import jmri.profile.Profile;
050import jmri.profile.ProfileManager;
051import jmri.util.FileUtil.Location;
052import jmri.util.FileUtil.Property;
053import org.slf4j.Logger;
054import org.slf4j.LoggerFactory;
055
056/**
057 * Support the {@link jmri.util.FileUtil} static API while providing
058 * {@link java.beans.PropertyChangeSupport} for listening to changes in the
059 * paths. Also provides the underlying implementation of all FileUtil methods so
060 * they can be exposed to scripts as an object methods instead of as static
061 * methods of a class.
062 *
063 * @author Randall Wood (C) 2015, 2016, 2019, 2020
064 */
065public class FileUtilSupport extends Bean {
066
067    /* User's home directory */
068    private static final String HOME_PATH = System.getProperty("user.home") + File.separator; // NOI18N
069    //
070    // Settable directories
071    //
072    /* JMRI program path, defaults to directory JMRI is executed from */
073    private String programPath = null;
074    /* path to jmri.jar */
075    private String jarPath = null;
076    /* path to the jython scripts directory */
077    private final HashMap<Profile, String> scriptsPaths = new HashMap<>();
078    /* path to the user's files directory */
079    private final HashMap<Profile, String> userFilesPaths = new HashMap<>();
080    /* path to profiles in use */
081    private final HashMap<Profile, String> profilePaths = new HashMap<>();
082
083    // initialize logging
084    private static final Logger log = LoggerFactory.getLogger(FileUtilSupport.class);
085    // default instance
086    volatile private static FileUtilSupport defaultInstance = null;
087
088    private FileUtilSupport() {
089        super(false);
090    }
091
092    /**
093     * Get the {@link java.io.File} that path refers to. Throws a
094     * {@link java.io.FileNotFoundException} if the file cannot be found instead
095     * of returning null (as File would). Use {@link #getURI(java.lang.String) }
096     * or {@link #getURL(java.lang.String) } instead of this method if possible.
097     *
098     * @param path the path to find
099     * @return {@link java.io.File} at path
100     * @throws java.io.FileNotFoundException if path cannot be found
101     * @see #getURI(java.lang.String)
102     * @see #getURL(java.lang.String)
103     */
104    @Nonnull
105    @CheckReturnValue
106    public File getFile(@Nonnull String path) throws FileNotFoundException {
107        return getFile(ProfileManager.getDefault().getActiveProfile(), path);
108    }
109
110    /**
111     * Get the {@link java.io.File} that path refers to. Throws a
112     * {@link java.io.FileNotFoundException} if the file cannot be found instead
113     * of returning null (as File would). Use {@link #getURI(java.lang.String) }
114     * or {@link #getURL(java.lang.String) } instead of this method if possible.
115     *
116     * @param profile the profile to use as a base
117     * @param path    the path to find
118     * @return {@link java.io.File} at path
119     * @throws java.io.FileNotFoundException if path cannot be found
120     * @see #getURI(java.lang.String)
121     * @see #getURL(java.lang.String)
122     */
123    @Nonnull
124    @CheckReturnValue
125    public File getFile(@CheckForNull Profile profile, @Nonnull String path) throws FileNotFoundException {
126        try {
127            return new File(this.pathFromPortablePath(profile, path));
128        } catch (NullPointerException ex) {
129            throw new FileNotFoundException("Cannot find file at " + path);
130        }
131    }
132
133    /**
134     * Get the {@link java.io.File} that path refers to. Throws a
135     * {@link java.io.FileNotFoundException} if the file cannot be found instead
136     * of returning null (as File would).
137     *
138     * @param path the path to find
139     * @return {@link java.io.File} at path
140     * @throws java.io.FileNotFoundException if path cannot be found
141     * @see #getFile(java.lang.String)
142     * @see #getURL(java.lang.String)
143     */
144    @Nonnull
145    @CheckReturnValue
146    public URI getURI(@Nonnull String path) throws FileNotFoundException {
147        return this.getFile(path).toURI();
148    }
149
150    /**
151     * Get the {@link java.net.URL} that path refers to. Throws a
152     * {@link java.io.FileNotFoundException} if the URL cannot be found instead
153     * of returning null.
154     *
155     * @param path the path to find
156     * @return {@link java.net.URL} at path
157     * @throws java.io.FileNotFoundException if path cannot be found
158     * @see #getFile(java.lang.String)
159     * @see #getURI(java.lang.String)
160     */
161    @Nonnull
162    @CheckReturnValue
163    public URL getURL(@Nonnull String path) throws FileNotFoundException {
164        try {
165            return this.getURI(path).toURL();
166        } catch (MalformedURLException ex) {
167            throw new FileNotFoundException("Cannot create URL for file at " + path);
168        }
169    }
170
171    /**
172     * Convenience method to get the {@link java.net.URL} from a
173     * {@link java.net.URI}. Logs errors and returns null if any exceptions are
174     * thrown by the conversion.
175     *
176     * @param uri The URI to convert.
177     * @return URL or null if any errors exist.
178     */
179    @CheckForNull
180    @CheckReturnValue
181    public URL getURL(@Nonnull URI uri) {
182        try {
183            return uri.toURL();
184        } catch (MalformedURLException | IllegalArgumentException ex) {
185            log.warn("Unable to get URL from {}", uri);
186            return null;
187        } catch (NullPointerException ex) {
188            log.warn("Unable to get URL from null object.", ex);
189            return null;
190        }
191    }
192
193    /**
194     * Find all files matching the given name under the given root directory
195     * within both the user and installed file locations.
196     *
197     * @param name the name of the file to find
198     * @param root the relative path to a directory in either or both of the
199     *             user or installed file locations; use a single period
200     *             character to refer to the root of the user or installed file
201     *             locations
202     * @return a set of found files or an empty set if no matching files were
203     *         found
204     * @throws IllegalArgumentException if the name is not a relative path, is
205     *                                  empty, or contains path separators; or
206     *                                  if the root is not a relative path, is
207     *                                  empty, or contains a parent directory
208     *                                  (..)
209     * @throws NullPointerException     if any parameter is null
210     */
211    @Nonnull
212    @CheckReturnValue
213    public Set<File> findFiles(@Nonnull String name, @Nonnull String root) throws IllegalArgumentException {
214        return this.findFiles(name, root, Location.ALL);
215    }
216
217    /**
218     * Find all files matching the given name under the given root directory
219     * within the specified location.
220     *
221     * @param name     the name of the file to find
222     * @param root     the relative path to a directory in either or both of the
223     *                 user or installed file locations; use a single period
224     *                 character to refer to the root of the location
225     * @param location the location to search within
226     * @return a set of found files or an empty set if no matching files were
227     *         found
228     * @throws IllegalArgumentException if the name is not a relative path, is
229     *                                  empty, or contains path separators; if
230     *                                  the root is not a relative path, is
231     *                                  empty, or contains a parent directory
232     *                                  (..); or if the location is
233     *                                  {@link Location#NONE}
234     * @throws NullPointerException     if any parameter is null
235     */
236    @Nonnull
237    @CheckReturnValue
238    public Set<File> findFiles(@Nonnull String name, @Nonnull String root, @Nonnull Location location) {
239        Objects.requireNonNull(name, "name must be nonnull");
240        Objects.requireNonNull(root, "root must be nonnull");
241        Objects.requireNonNull(location, "location must be nonnull");
242        if (location == Location.NONE) {
243            throw new IllegalArgumentException("location must not be NONE");
244        }
245        if (root.isEmpty() || root.contains("..") || root.startsWith("/")) {
246            throw new IllegalArgumentException("root is invalid");
247        }
248        if (name.isEmpty() || name.contains(File.pathSeparator) || name.contains("/")) {
249            throw new IllegalArgumentException("name is invalid");
250        }
251        Set<File> files = new HashSet<>();
252        if (location == Location.INSTALLED || location == Location.ALL) {
253            files.addAll(this.findFiles(name, new File(this.findURI(PROGRAM + root, Location.NONE))));
254        }
255        if (location == Location.USER || location == Location.ALL) {
256            try {
257                files.addAll(this.findFiles(name, new File(this.findURI(PREFERENCES + root, Location.NONE))));
258            } catch (NullPointerException ex) {
259                // expected if path PREFERENCES + root does not exist
260                log.trace("{} does not exist in {}", root, PREFERENCES);
261            }
262            try {
263                files.addAll(this.findFiles(name, new File(this.findURI(PROFILE + root, Location.NONE))));
264            } catch (NullPointerException ex) {
265                // expected if path PROFILE + root does not exist
266                log.trace("{} does not exist in {}", root, PROFILE);
267            }
268            try {
269                files.addAll(this.findFiles(name, new File(this.findURI(SETTINGS + root, Location.NONE))));
270            } catch (NullPointerException ex) {
271                // expected if path SETTINGS + root does not exist
272                log.trace("{} does not exist in {}", root, SETTINGS);
273            }
274        }
275        return files;
276    }
277
278    private Set<File> findFiles(String name, File root) {
279        Set<File> files = new HashSet<>();
280        if (root.isDirectory()) {
281            try {
282                Files.walkFileTree(root.toPath(), new SimpleFileVisitor<Path>() {
283                    @Override
284                    public FileVisitResult preVisitDirectory(final Path dir,
285                            final BasicFileAttributes attrs) throws IOException {
286
287                        Path fn = dir.getFileName();
288                        if (fn != null && name.equals(fn.toString())) {
289                            files.add(dir.toFile().getCanonicalFile());
290                        }
291                        return FileVisitResult.CONTINUE;
292                    }
293
294                    @Override
295                    public FileVisitResult visitFile(final Path file,
296                            final BasicFileAttributes attrs) throws IOException {
297                        // TODO: accept glob patterns
298                        Path fn = file.getFileName();
299                        if (fn != null && name.equals(fn.toString())) {
300                            files.add(file.toFile().getCanonicalFile());
301                        }
302                        return FileVisitResult.CONTINUE;
303                    }
304                });
305            } catch (IOException ex) {
306                log.warn("Exception while finding file {} in {}", name, root, ex);
307            }
308        }
309        return files;
310    }
311
312    /**
313     * Get the resource file corresponding to a name. There are five cases:
314     * <ul>
315     * <li>Starts with "resource:", treat the rest as a pathname relative to the
316     * program directory (deprecated; see "program:" below)</li>
317     * <li>Starts with "program:", treat the rest as a relative pathname below
318     * the program directory</li>
319     * <li>Starts with "preference:", treat the rest as a relative path below
320     * the user's files directory</li>
321     * <li>Starts with "settings:", treat the rest as a relative path below the
322     * JMRI system preferences directory</li>
323     * <li>Starts with "home:", treat the rest as a relative path below the
324     * user.home directory</li>
325     * <li>Starts with "file:", treat the rest as a relative path below the
326     * resource directory in the preferences directory (deprecated; see
327     * "preference:" above)</li>
328     * <li>Starts with "profile:", treat the rest as a relative path below the
329     * profile directory as specified in the
330     * active{@link jmri.profile.Profile}</li>
331     * <li>Starts with "scripts:", treat the rest as a relative path below the
332     * scripts directory</li>
333     * <li>Otherwise, treat the name as a relative path below the program
334     * directory</li>
335     * </ul>
336     * In any case, absolute pathnames will work. Uses the Profile returned by
337     * {@link ProfileManager#getActiveProfile()} as the base.
338     *
339     * @param pName the name, possibly starting with file:, home:, profile:,
340     *              program:, preference:, scripts:, settings, or resource:
341     * @return Absolute file name to use. This will include system-specific file
342     *         separators.
343     * @since 2.7.2
344     */
345    @Nonnull
346    @CheckReturnValue
347    public String getExternalFilename(@Nonnull String pName) {
348        return getExternalFilename(ProfileManager.getDefault().getActiveProfile(), pName);
349    }
350
351    /**
352     * Get the resource file corresponding to a name. There are five cases:
353     * <ul>
354     * <li>Starts with "resource:", treat the rest as a pathname relative to the
355     * program directory (deprecated; see "program:" below)</li>
356     * <li>Starts with "program:", treat the rest as a relative pathname below
357     * the program directory</li>
358     * <li>Starts with "preference:", treat the rest as a relative path below
359     * the user's files directory</li>
360     * <li>Starts with "settings:", treat the rest as a relative path below the
361     * JMRI system preferences directory</li>
362     * <li>Starts with "home:", treat the rest as a relative path below the
363     * user.home directory</li>
364     * <li>Starts with "file:", treat the rest as a relative path below the
365     * resource directory in the preferences directory (deprecated; see
366     * "preference:" above)</li>
367     * <li>Starts with "profile:", treat the rest as a relative path below the
368     * profile directory as specified in the
369     * active{@link jmri.profile.Profile}</li>
370     * <li>Starts with "scripts:", treat the rest as a relative path below the
371     * scripts directory</li>
372     * <li>Otherwise, treat the name as a relative path below the program
373     * directory</li>
374     * </ul>
375     * In any case, absolute pathnames will work.
376     *
377     * @param profile the Profile to use as a base
378     * @param pName   the name, possibly starting with file:, home:, profile:,
379     *                program:, preference:, scripts:, settings, or resource:
380     * @return Absolute file name to use. This will include system-specific file
381     *         separators.
382     * @since 4.17.3
383     */
384    @Nonnull
385    @CheckReturnValue
386    public String getExternalFilename(@CheckForNull Profile profile, @Nonnull String pName) {
387        String filename = this.pathFromPortablePath(profile, pName);
388        return (filename != null) ? filename : pName.replace(SEPARATOR, File.separatorChar);
389    }
390
391    /**
392     * Convert a portable filename into an absolute filename, using
393     * {@link jmri.profile.ProfileManager#getActiveProfile()} as the base.
394     *
395     * @param path the portable filename
396     * @return An absolute filename
397     */
398    @Nonnull
399    @CheckReturnValue
400    public String getAbsoluteFilename(@Nonnull String path) {
401        return this.getAbsoluteFilename(ProfileManager.getDefault().getActiveProfile(), path);
402    }
403
404    /**
405     * Convert a portable filename into an absolute filename.
406     *
407     * @param profile the profile to use as the base
408     * @param path    the portable filename
409     * @return An absolute filename
410     */
411    @Nonnull
412    @CheckReturnValue
413    public String getAbsoluteFilename(@CheckForNull Profile profile, @Nonnull String path) {
414        return this.pathFromPortablePath(profile, path);
415    }
416
417    /**
418     * Convert a File object's path to our preferred storage form.
419     * <p>
420     * This is the inverse of {@link #getFile(String pName)}. Deprecated forms
421     * are not created.
422     *
423     * @param file File at path to be represented
424     * @return Filename for storage in a portable manner. This will include
425     *         portable, not system-specific, file separators.
426     * @since 2.7.2
427     */
428    @Nonnull
429    @CheckReturnValue
430    public String getPortableFilename(@Nonnull File file) {
431        return this.getPortableFilename(ProfileManager.getDefault().getActiveProfile(), file, false, false);
432    }
433
434    /**
435     * Convert a File object's path to our preferred storage form.
436     * <p>
437     * This is the inverse of {@link #getFile(String pName)}. Deprecated forms
438     * are not created.
439     * <p>
440     * This method supports a specific use case concerning profiles and other
441     * portable paths that are stored within the User files directory, which
442     * will cause the {@link jmri.profile.ProfileManager} to write an incorrect
443     * path for the current profile or
444     * {@link apps.configurexml.FileLocationPaneXml} to write an incorrect path
445     * for the Users file directory. In most cases, the use of
446     * {@link #getPortableFilename(java.io.File)} is preferable.
447     *
448     * @param file                File at path to be represented
449     * @param ignoreUserFilesPath true if paths in the User files path should be
450     *                            stored as absolute paths, which is often not
451     *                            desirable.
452     * @param ignoreProfilePath   true if paths in the profile should be stored
453     *                            as absolute paths, which is often not
454     *                            desirable.
455     * @return Storage format representation
456     * @since 3.5.5
457     */
458    @Nonnull
459    @CheckReturnValue
460    public String getPortableFilename(@Nonnull File file, boolean ignoreUserFilesPath, boolean ignoreProfilePath) {
461        return getPortableFilename(ProfileManager.getDefault().getActiveProfile(), file, ignoreUserFilesPath, ignoreProfilePath);
462    }
463
464    /**
465     * Convert a filename string to our preferred storage form.
466     * <p>
467     * This is the inverse of {@link #getExternalFilename(String pName)}.
468     * Deprecated forms are not created.
469     *
470     * @param filename Filename to be represented
471     * @return Filename for storage in a portable manner
472     * @since 2.7.2
473     */
474    @Nonnull
475    @CheckReturnValue
476    public String getPortableFilename(@Nonnull String filename) {
477        return getPortableFilename(ProfileManager.getDefault().getActiveProfile(), filename, false, false);
478    }
479
480    /**
481     * Convert a filename string to our preferred storage form.
482     * <p>
483     * This is the inverse of {@link #getExternalFilename(String pName)}.
484     * Deprecated forms are not created.
485     * <p>
486     * This method supports a specific use case concerning profiles and other
487     * portable paths that are stored within the User files directory, which
488     * will cause the {@link jmri.profile.ProfileManager} to write an incorrect
489     * path for the current profile or
490     * {@link apps.configurexml.FileLocationPaneXml} to write an incorrect path
491     * for the Users file directory. In most cases, the use of
492     * {@link #getPortableFilename(java.io.File)} is preferable.
493     *
494     * @param filename            Filename to be represented
495     * @param ignoreUserFilesPath true if paths in the User files path should be
496     *                            stored as absolute paths, which is often not
497     *                            desirable.
498     * @param ignoreProfilePath   true if paths in the profile path should be
499     *                            stored as absolute paths, which is often not
500     *                            desirable.
501     * @return Storage format representation
502     * @since 3.5.5
503     */
504    @Nonnull
505    @CheckReturnValue
506    public String getPortableFilename(@Nonnull String filename, boolean ignoreUserFilesPath, boolean ignoreProfilePath) {
507        if (this.isPortableFilename(filename)) {
508            // if this already contains prefix, run through conversion to normalize
509            return getPortableFilename(getExternalFilename(filename), ignoreUserFilesPath, ignoreProfilePath);
510        } else {
511            // treat as pure filename
512            return getPortableFilename(new File(filename), ignoreUserFilesPath, ignoreProfilePath);
513        }
514    }
515
516    /**
517     * Convert a File object's path to our preferred storage form.
518     * <p>
519     * This is the inverse of {@link #getFile(String pName)}. Deprecated forms
520     * are not created.
521     *
522     * @param profile Profile to use as base
523     * @param file    File at path to be represented
524     * @return Filename for storage in a portable manner. This will include
525     *         portable, not system-specific, file separators.
526     * @since 4.17.3
527     */
528    @Nonnull
529    @CheckReturnValue
530    public String getPortableFilename(@CheckForNull Profile profile, @Nonnull File file) {
531        return this.getPortableFilename(profile, file, false, false);
532    }
533
534    /**
535     * Convert a File object's path to our preferred storage form.
536     * <p>
537     * This is the inverse of {@link #getFile(String pName)}. Deprecated forms
538     * are not created.
539     * <p>
540     * This method supports a specific use case concerning profiles and other
541     * portable paths that are stored within the User files directory, which
542     * will cause the {@link jmri.profile.ProfileManager} to write an incorrect
543     * path for the current profile or
544     * {@link apps.configurexml.FileLocationPaneXml} to write an incorrect path
545     * for the Users file directory. In most cases, the use of
546     * {@link #getPortableFilename(java.io.File)} is preferable.
547     *
548     * @param profile             Profile to use as base
549     * @param file                File at path to be represented
550     * @param ignoreUserFilesPath true if paths in the User files path should be
551     *                            stored as absolute paths, which is often not
552     *                            desirable.
553     * @param ignoreProfilePath   true if paths in the profile should be stored
554     *                            as absolute paths, which is often not
555     *                            desirable.
556     * @return Storage format representation
557     * @since 3.5.5
558     */
559    @Nonnull
560    @CheckReturnValue
561    public String getPortableFilename(@CheckForNull Profile profile, @Nonnull File file, boolean ignoreUserFilesPath, boolean ignoreProfilePath) {
562        // compare full path name to see if same as preferences
563        String filename = file.getAbsolutePath();
564
565        // append separator if file is a directory
566        if (file.isDirectory()) {
567            filename = filename + File.separator;
568        }
569
570        if (filename == null) {
571            throw new IllegalArgumentException("File \"" + file + "\" has a null absolute path which is not allowed");
572        }
573
574        // compare full path name to see if same as preferences
575        if (!ignoreUserFilesPath) {
576            if (filename.startsWith(getUserFilesPath(profile))) {
577                return PREFERENCES
578                        + filename.substring(getUserFilesPath(profile).length(), filename.length()).replace(File.separatorChar,
579                                SEPARATOR);
580            }
581        }
582
583        if (!ignoreProfilePath) {
584            // compare full path name to see if same as profile
585            if (filename.startsWith(getProfilePath(profile))) {
586                return PROFILE
587                        + filename.substring(getProfilePath(profile).length(), filename.length()).replace(File.separatorChar,
588                                SEPARATOR);
589            }
590        }
591
592        // compare full path name to see if same as settings
593        if (filename.startsWith(getPreferencesPath())) {
594            return SETTINGS
595                    + filename.substring(getPreferencesPath().length(), filename.length()).replace(File.separatorChar,
596                            SEPARATOR);
597        }
598
599        if (!ignoreUserFilesPath) {
600            /*
601             * The tests for any portatable path that could be within the
602             * UserFiles locations needs to be within this block. This prevents
603             * the UserFiles or Profile path from being set to another portable
604             * path that is user settable.
605             *
606             * Note that this test should be after the UserFiles, Profile, and
607             * Preferences tests.
608             */
609            // check for relative to scripts dir
610            if (filename.startsWith(getScriptsPath(profile)) && !filename.equals(getScriptsPath(profile))) {
611                return SCRIPTS
612                        + filename.substring(getScriptsPath(profile).length(), filename.length()).replace(File.separatorChar,
613                                SEPARATOR);
614            }
615        }
616
617        // now check for relative to program dir
618        if (filename.startsWith(getProgramPath())) {
619            return PROGRAM
620                    + filename.substring(getProgramPath().length(), filename.length()).replace(File.separatorChar,
621                            SEPARATOR);
622        }
623
624        // compare full path name to see if same as home directory
625        // do this last, in case preferences or program dir are in home directory
626        if (filename.startsWith(getHomePath())) {
627            return HOME
628                    + filename.substring(getHomePath().length(), filename.length()).replace(File.separatorChar,
629                            SEPARATOR);
630        }
631
632        return filename.replace(File.separatorChar, SEPARATOR); // absolute, and doesn't match; not really portable...
633    }
634
635    /**
636     * Convert a filename string to our preferred storage form.
637     * <p>
638     * This is the inverse of {@link #getExternalFilename(String pName)}.
639     * Deprecated forms are not created.
640     *
641     * @param profile  Profile to use as base
642     * @param filename Filename to be represented
643     * @return Filename for storage in a portable manner
644     * @since 4.17.3
645     */
646    @Nonnull
647    @CheckReturnValue
648    public String getPortableFilename(@CheckForNull Profile profile, @Nonnull String filename) {
649        return getPortableFilename(profile, filename, false, false);
650    }
651
652    /**
653     * Convert a filename string to our preferred storage form.
654     * <p>
655     * This is the inverse of {@link #getExternalFilename(String pName)}.
656     * Deprecated forms are not created.
657     * <p>
658     * This method supports a specific use case concerning profiles and other
659     * portable paths that are stored within the User files directory, which
660     * will cause the {@link jmri.profile.ProfileManager} to write an incorrect
661     * path for the current profile or
662     * {@link apps.configurexml.FileLocationPaneXml} to write an incorrect path
663     * for the Users file directory. In most cases, the use of
664     * {@link #getPortableFilename(java.io.File)} is preferable.
665     *
666     * @param profile             Profile to use as base
667     * @param filename            Filename to be represented
668     * @param ignoreUserFilesPath true if paths in the User files path should be
669     *                            stored as absolute paths, which is often not
670     *                            desirable.
671     * @param ignoreProfilePath   true if paths in the profile path should be
672     *                            stored as absolute paths, which is often not
673     *                            desirable.
674     * @return Storage format representation
675     * @since 4.17.3
676     */
677    @Nonnull
678    @CheckReturnValue
679    public String getPortableFilename(@CheckForNull Profile profile, @Nonnull String filename, boolean ignoreUserFilesPath,
680            boolean ignoreProfilePath) {
681        if (isPortableFilename(filename)) {
682            // if this already contains prefix, run through conversion to normalize
683            return getPortableFilename(profile, getExternalFilename(filename), ignoreUserFilesPath, ignoreProfilePath);
684        } else {
685            // treat as pure filename
686            return getPortableFilename(profile, new File(filename), ignoreUserFilesPath, ignoreProfilePath);
687        }
688    }
689
690    /**
691     * Test if the given filename is a portable filename.
692     * <p>
693     * Note that this method may return a false positive if the filename is a
694     * file: URL.
695     *
696     * @param filename the name to test
697     * @return true if filename is portable
698     */
699    public boolean isPortableFilename(@Nonnull String filename) {
700        return (filename.startsWith(PROGRAM)
701                || filename.startsWith(HOME)
702                || filename.startsWith(PREFERENCES)
703                || filename.startsWith(SCRIPTS)
704                || filename.startsWith(PROFILE)
705                || filename.startsWith(SETTINGS));
706    }
707
708    /**
709     * Get the user's home directory.
710     *
711     * @return User's home directory as a String
712     */
713    @Nonnull
714    @CheckReturnValue
715    public String getHomePath() {
716        return HOME_PATH;
717    }
718
719    /**
720     * Get the user's files directory. If not set by the user, this is the same
721     * as the profile path returned by
722     * {@link ProfileManager#getActiveProfile()}. Note that if the profile path
723     * has been set to null, that returns the preferences directory, see
724     * {@link #getProfilePath()}.
725     *
726     * @see #getProfilePath()
727     * @return User's files directory
728     */
729    @Nonnull
730    @CheckReturnValue
731    public String getUserFilesPath() {
732        return getUserFilesPath(ProfileManager.getDefault().getActiveProfile());
733    }
734
735    /**
736     * Get the user's files directory. If not set by the user, this is the same
737     * as the profile path. Note that if the profile path has been set to null,
738     * that returns the preferences directory, see {@link #getProfilePath()}.
739     *
740     * @param profile the profile to use
741     * @see #getProfilePath()
742     * @return User's files directory
743     */
744    @Nonnull
745    @CheckReturnValue
746    public String getUserFilesPath(@CheckForNull Profile profile) {
747        String path = userFilesPaths.get(profile);
748        return path != null ? path : getProfilePath(profile);
749    }
750
751    /**
752     * Set the user's files directory.
753     *
754     * @see #getUserFilesPath()
755     * @param profile the profile to set the user's files directory for
756     * @param path    The path to the user's files directory using
757     *                system-specific separators
758     */
759    public void setUserFilesPath(@CheckForNull Profile profile, @Nonnull String path) {
760        String old = userFilesPaths.get(profile);
761        if (!path.endsWith(File.separator)) {
762            path = path + File.separator;
763        }
764        userFilesPaths.put(profile, path);
765        if ((old != null && !old.equals(path)) || (!path.equals(old))) {
766            this.firePropertyChange(FileUtil.PREFERENCES, new Property(profile, old), new Property(profile, path));
767        }
768    }
769
770    /**
771     * Get the profile directory. If not set, provide the preferences path.
772     *
773     * @param profile the Profile to use as a base
774     * @see #getPreferencesPath()
775     * @return Profile directory using system-specific separators
776     */
777    @Nonnull
778    @CheckReturnValue
779    public String getProfilePath(@CheckForNull Profile profile) {
780        String path = profilePaths.get(profile);
781        if (path == null) {
782            File f = profile != null ? profile.getPath() : null;
783            if (f != null) {
784                path = f.getAbsolutePath();
785                if (!path.endsWith(File.separator)) {
786                    path = path + File.separator;
787                }
788                profilePaths.put(profile, path);
789            }
790        }
791        return (path != null) ? path : this.getPreferencesPath();
792    }
793
794    /**
795     * Get the profile directory. If not set, provide the preferences path. Uses
796     * the Profile returned by {@link ProfileManager#getActiveProfile()} as a
797     * base.
798     *
799     * @see #getPreferencesPath()
800     * @return Profile directory using system-specific separators
801     */
802    @Nonnull
803    @CheckReturnValue
804    public String getProfilePath() {
805        return getProfilePath(ProfileManager.getDefault().getActiveProfile());
806    }
807
808    /**
809     * Get the preferences directory. This directory is set based on the OS and
810     * is not normally settable by the user.
811     * <ul>
812     * <li>On Microsoft Windows systems, this is {@code JMRI} in the User's home
813     * directory.</li>
814     * <li>On OS X systems, this is {@code Library/Preferences/JMRI} in the
815     * User's home directory.</li>
816     * <li>On Linux, Solaris, and other UNIXes, this is {@code .jmri} in the
817     * User's home directory.</li>
818     * <li>This can be overridden with by setting the {@code jmri.prefsdir} Java
819     * property when starting JMRI.</li>
820     * </ul>
821     * Use {@link #getHomePath()} to get the User's home directory.
822     *
823     * @see #getHomePath()
824     * @return Path to the preferences directory using system-specific
825     *         separators.
826     */
827    @Nonnull
828    @CheckReturnValue
829    public String getPreferencesPath() {
830        // return jmri.prefsdir property if present
831        String jmriPrefsDir = System.getProperty("jmri.prefsdir", ""); // NOI18N
832        if (!jmriPrefsDir.isEmpty()) {
833            try {
834                return new File(jmriPrefsDir).getCanonicalPath() + File.separator;
835            } catch (IOException ex) {
836                // use System.err because logging at this point will fail
837                // since this method is called to setup logging
838                System.err.println("Unable to locate settings dir \"" + jmriPrefsDir + "\"");
839                if (!jmriPrefsDir.endsWith(File.separator)) {
840                    return jmriPrefsDir + File.separator;
841                }
842            }
843        }
844        String result;
845        switch (SystemType.getType()) {
846            case SystemType.MACOSX:
847                // Mac OS X
848                result = this.getHomePath() + "Library" + File.separator + "Preferences" + File.separator + "JMRI" + File.separator; // NOI18N
849                break;
850            case SystemType.LINUX:
851            case SystemType.UNIX:
852                // Linux, so use an invisible file
853                result = this.getHomePath() + ".jmri" + File.separator; // NOI18N
854                break;
855            case SystemType.WINDOWS:
856            default:
857                // Could be Windows, other
858                result = this.getHomePath() + "JMRI" + File.separator; // NOI18N
859                break;
860        }
861        // logging here merely throws warnings since we call this method to set up logging
862        // uncomment below to print OS default to console
863        // System.out.println("preferencesPath defined as \"" + result + "\" based on os.name=\"" + SystemType.getOSName() + "\"");
864        return result;
865    }
866
867    /**
868     * Get the JMRI cache location, ensuring its existence.
869     * <p>
870     * This is <strong>not</strong> part of the {@link jmri.util.FileUtil} API
871     * since it should generally be accessed using
872     * {@link jmri.profile.ProfileUtils#getCacheDirectory(jmri.profile.Profile, java.lang.Class)}.
873     * <p>
874     * Uses the following locations (where [version] is from
875     * {@link jmri.Version#getCanonicalVersion()}):
876     * <dl>
877     * <dt>System Property (if set)</dt><dd>value of
878     * <em>jmri_default_cachedir</em></dd>
879     * <dt>macOS</dt><dd>~/Library/Caches/JMRI/[version]</dd>
880     * <dt>Windows</dt><dd>%Local AppData%/JMRI/[version]</dd>
881     * <dt>UNIX/Linux/POSIX</dt><dd>${XDG_CACHE_HOME}/JMRI/[version] or
882     * $HOME/.cache/JMRI/[version]</dd>
883     * <dt>Fallback</dt><dd>JMRI portable path
884     * <em>setting:cache/[version]</em></dd>
885     * </dl>
886     *
887     * @return the cache directory for this version of JMRI
888     */
889    @Nonnull
890    public File getCacheDirectory() {
891        File cache;
892        String property = System.getProperty("jmri_default_cachedir");
893        if (property != null) {
894            cache = new File(property);
895        } else {
896            switch (SystemType.getType()) {
897                case SystemType.MACOSX:
898                    cache = new File(new File(this.getHomePath(), "Library/Caches/JMRI"), Version.getCanonicalVersion());
899                    break;
900                case SystemType.LINUX:
901                case SystemType.UNIX:
902                    property = System.getenv("XDG_CACHE_HOME");
903                    if (property != null) {
904                        cache = new File(new File(property, "JMRI"), Version.getCanonicalVersion());
905                    } else {
906                        cache = new File(new File(this.getHomePath(), ".cache/JMRI"), Version.getCanonicalVersion());
907                    }
908                    break;
909                case SystemType.WINDOWS:
910                    try {
911                        cache = new File(new File(Shell32Util.getKnownFolderPath(KnownFolders.FOLDERID_LocalAppData), "JMRI/cache"), Version.getCanonicalVersion());
912                    } catch (UnsatisfiedLinkError er) {
913                        // Needed only on Windows XP
914                        cache = new File(new File(Shell32Util.getFolderPath(ShlObj.CSIDL_LOCAL_APPDATA), "JMRI/cache"), Version.getCanonicalVersion());
915                    }
916                    break;
917                default:
918                    // fallback
919                    cache = new File(new File(this.getPreferencesPath(), "cache"), Version.getCanonicalVersion());
920                    break;
921            }
922        }
923        this.createDirectory(cache);
924        return cache;
925    }
926
927    /**
928     * Get the JMRI program directory.
929     * <p>
930     * If the program directory has not been
931     * previously set, first sets the program directory to the value specified
932     * in the Java System property <code>jmri.path.program</code>
933     * <p>
934     * If this property is unset, finds from jar or class files location.
935     * <p>
936     * If this fails, returns <code>.</code> .
937     *
938     * @return JMRI program directory as a String.
939     */
940    @Nonnull
941    @CheckReturnValue
942    public String getProgramPath() {
943        // As this method is called in Log4J setup, should not
944        // contain standard logging statements.
945        if (programPath == null) {
946            if (System.getProperty("jmri.path.program") == null) {
947                // find from jar or class files location
948                String path1 = this.getClass().getProtectionDomain().getCodeSource().getLocation().getPath();
949                String path2 = (new File(path1)).getParentFile().getPath();
950                path2 = path2.replaceAll("\\+", "%2B"); // convert + chars to UTF-8 to get through the decode
951                try {
952                    String loadingDir = java.net.URLDecoder.decode(path2, "UTF-8");
953                    if (loadingDir.endsWith("target")) {
954                        loadingDir = loadingDir.substring(0, loadingDir.length()-6);
955                    }
956                     this.setProgramPath(loadingDir); // NOI18N
957               } catch (java.io.UnsupportedEncodingException e) {
958                    System.err.println("Unsupported URL when trying to locate program directory: " + path2 );
959                    // best guess
960                    this.setProgramPath("."); // NOI18N
961                }
962            } else {
963                this.setProgramPath(System.getProperty("jmri.path.program", ".")); // NOI18N
964            }
965        }
966        return programPath;
967    }
968
969    /**
970     * Set the JMRI program directory.
971     * <p>
972     * Convenience method that calls {@link #setProgramPath(java.io.File)} with
973     * the passed in path.
974     *
975     * @param path the path to the JMRI installation
976     */
977    public void setProgramPath(@Nonnull String path) {
978        this.setProgramPath(new File(path));
979    }
980
981    /**
982     * Set the JMRI program directory.
983     * <p>
984     * If set, allows JMRI to be loaded from locations other than the directory
985     * containing JMRI resources. This must be set very early in the process of
986     * loading JMRI (prior to loading any other JMRI code) to be meaningfully
987     * used.
988     *
989     * @param path the path to the JMRI installation
990     */
991    public void setProgramPath(@Nonnull File path) {
992        String old = this.programPath;
993        try {
994            this.programPath = (path).getCanonicalPath() + File.separator;
995        } catch (IOException ex) {
996            log.error("Unable to get JMRI program directory.", ex);
997        }
998        if ((old != null && !old.equals(this.programPath))
999                || (this.programPath != null && !this.programPath.equals(old))) {
1000            this.firePropertyChange(FileUtil.PROGRAM, old, this.programPath);
1001        }
1002    }
1003
1004    /**
1005     * Get the resources directory within the user's files directory.
1006     *
1007     * @return path to [user's file]/resources/ using system-specific separators
1008     */
1009    @Nonnull
1010    @CheckReturnValue
1011    public String getUserResourcePath() {
1012        return this.getUserFilesPath() + "resources" + File.separator; // NOI18N
1013    }
1014
1015    /**
1016     * Log all paths at the INFO level.
1017     */
1018    public void logFilePaths() {
1019        log.info("File path {} is {}", FileUtil.PROGRAM, this.getProgramPath());
1020        log.info("File path {} is {}", FileUtil.PREFERENCES, this.getUserFilesPath());
1021        log.info("File path {} is {}", FileUtil.PROFILE, this.getProfilePath());
1022        log.info("File path {} is {}", FileUtil.SETTINGS, this.getPreferencesPath());
1023        log.info("File path {} is {}", FileUtil.HOME, this.getHomePath());
1024        log.info("File path {} is {}", FileUtil.SCRIPTS, this.getScriptsPath());
1025    }
1026
1027    /**
1028     * Get the path to the scripts directory. If not set previously with
1029     * {@link #setScriptsPath}, this is the "jython" subdirectory in the program
1030     * directory. Uses the Profile returned by
1031     * {@link ProfileManager#getActiveProfile()} as the base.
1032     *
1033     * @return the scripts directory using system-specific separators
1034     */
1035    @Nonnull
1036    @CheckReturnValue
1037    public String getScriptsPath() {
1038        return getScriptsPath(ProfileManager.getDefault().getActiveProfile());
1039    }
1040
1041    /**
1042     * Get the path to the scripts directory. If not set previously with
1043     * {@link #setScriptsPath}, this is the "jython" subdirectory in the program
1044     * directory.
1045     *
1046     * @param profile the Profile to use as the base
1047     * @return the path to scripts directory using system-specific separators
1048     */
1049    @Nonnull
1050    @CheckReturnValue
1051    public String getScriptsPath(@CheckForNull Profile profile) {
1052        String path = scriptsPaths.get(profile);
1053        if (path != null) {
1054            return path;
1055        }
1056        // scripts directory not set by user, return default if it exists
1057        File file = new File(this.getProgramPath() + File.separator + "jython" + File.separator); // NOI18N
1058        if (file.exists() && file.isDirectory()) {
1059            return file.getPath() + File.separator;
1060        }
1061        // if default does not exist, return user's files directory
1062        return this.getUserFilesPath();
1063    }
1064
1065    /**
1066     * Set the path to python scripts.
1067     *
1068     * @param profile the profile to use as a base
1069     * @param path    the scriptsPaths to set; null resets to the default,
1070     *                defined in {@link #getScriptsPath()}
1071     */
1072    public void setScriptsPath(@CheckForNull Profile profile, @CheckForNull String path) {
1073        String old = scriptsPaths.get(profile);
1074        if (path != null && !path.endsWith(File.separator)) {
1075            path = path + File.separator;
1076        }
1077        scriptsPaths.put(profile, path);
1078        if ((old != null && !old.equals(path)) || (path != null && !path.equals(old))) {
1079            this.firePropertyChange(FileUtil.SCRIPTS, new Property(profile, old), new Property(profile, path));
1080        }
1081    }
1082
1083    /**
1084     * Get the URL of a portable filename if it can be located using
1085     * {@link #findURI(java.lang.String)}
1086     *
1087     * @param path the path to find
1088     * @return URL of portable or absolute path
1089     */
1090    @Nonnull
1091    @CheckReturnValue
1092    public URI findExternalFilename(@Nonnull String path) {
1093        log.debug("Finding external path {}", path);
1094        if (this.isPortableFilename(path)) {
1095            int index = path.indexOf(":") + 1;
1096            String location = path.substring(0, index);
1097            path = path.substring(index);
1098            log.debug("Finding {} and {}", location, path);
1099            switch (location) {
1100                case FileUtil.PROGRAM:
1101                    return this.findURI(path, FileUtil.Location.INSTALLED);
1102                case FileUtil.PREFERENCES:
1103                    return this.findURI(path, FileUtil.Location.USER);
1104                case FileUtil.PROFILE:
1105                case FileUtil.SETTINGS:
1106                case FileUtil.SCRIPTS:
1107                case FileUtil.HOME:
1108                    return this.findURI(this.getExternalFilename(location + path));
1109                default:
1110                    break;
1111            }
1112        }
1113        return this.findURI(path, Location.ALL);
1114    }
1115
1116    /**
1117     * Search for a file or JAR resource by name and return the
1118     * {@link java.io.InputStream} for that file. Search order is defined by
1119     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...) }.
1120     * No limits are placed on search locations.
1121     *
1122     * @param path The relative path of the file or resource
1123     * @return InputStream or null.
1124     * @see #findInputStream(java.lang.String, java.lang.String...)
1125     * @see #findInputStream(java.lang.String, jmri.util.FileUtil.Location,
1126     * java.lang.String...)
1127     * @see #findURL(java.lang.String)
1128     * @see #findURL(java.lang.String, java.lang.String...)
1129     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location,
1130     * java.lang.String...)
1131     */
1132    public InputStream findInputStream(@Nonnull String path) {
1133        return this.findInputStream(path, new String[]{});
1134    }
1135
1136    /**
1137     * Search for a file or JAR resource by name and return the
1138     * {@link java.io.InputStream} for that file. Search order is defined by
1139     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...) }.
1140     * No limits are placed on search locations.
1141     *
1142     * @param path        The relative path of the file or resource
1143     * @param searchPaths a list of paths to search for the path in
1144     * @return InputStream or null.
1145     * @see #findInputStream(java.lang.String)
1146     * @see #findInputStream(java.lang.String, jmri.util.FileUtil.Location,
1147     * java.lang.String...)
1148     */
1149    public InputStream findInputStream(@Nonnull String path, @Nonnull String... searchPaths) {
1150        return this.findInputStream(path, Location.ALL, searchPaths);
1151    }
1152
1153    /**
1154     * Search for a file or JAR resource by name and return the
1155     * {@link java.io.InputStream} for that file. Search order is defined by
1156     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...) }.
1157     *
1158     * @param path      The relative path of the file or resource
1159     * @param locations The type of locations to limit the search to
1160     * @return InputStream or null.
1161     * @see #findInputStream(java.lang.String)
1162     * @see #findInputStream(java.lang.String, jmri.util.FileUtil.Location,
1163     * java.lang.String...)
1164     */
1165    public InputStream findInputStream(@Nonnull String path, @Nonnull Location locations) {
1166        return this.findInputStream(path, locations, new String[]{});
1167    }
1168
1169    /**
1170     * Search for a file or JAR resource by name and return the
1171     * {@link java.io.InputStream} for that file. Search order is defined by
1172     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...) }.
1173     *
1174     * @param path        The relative path of the file or resource
1175     * @param locations   The type of locations to limit the search to
1176     * @param searchPaths a list of paths to search for the path in
1177     * @return InputStream or null.
1178     * @see #findInputStream(java.lang.String)
1179     * @see #findInputStream(java.lang.String, java.lang.String...)
1180     */
1181    public InputStream findInputStream(@Nonnull String path, @Nonnull Location locations, @Nonnull String... searchPaths) {
1182        URL file = this.findURL(path, locations, searchPaths);
1183        if (file != null) {
1184            try {
1185                return file.openStream();
1186            } catch (IOException ex) {
1187                log.error("findInputStream IOException", ex);
1188            }
1189        }
1190        return null;
1191    }
1192
1193    /**
1194     * Search for a file or JAR resource by name and return the
1195     * {@link java.net.URI} for that file. Search order is defined by
1196     * {@link #findURI(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1197     * No limits are placed on search locations.
1198     *
1199     * @param path The relative path of the file or resource.
1200     * @return The URI or null.
1201     * @see #findURI(java.lang.String, java.lang.String...)
1202     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location)
1203     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location,
1204     * java.lang.String...)
1205     */
1206    public URI findURI(@Nonnull String path) {
1207        return this.findURI(path, new String[]{});
1208    }
1209
1210    /**
1211     * Search for a file or JAR resource by name and return the
1212     * {@link java.net.URI} for that file. Search order is defined by
1213     * {@link #findURI(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1214     * No limits are placed on search locations.
1215     * <p>
1216     * Note that if the file for path is not found in one of the searchPaths,
1217     * all standard locations are also be searched through to find the file. If
1218     * you need to limit the locations where the file can be found use
1219     * {@link #findURI(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1220     *
1221     * @param path        The relative path of the file or resource
1222     * @param searchPaths a list of paths to search for the path in
1223     * @return The URI or null
1224     * @see #findURI(java.lang.String)
1225     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location)
1226     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location,
1227     * java.lang.String...)
1228     */
1229    public URI findURI(@Nonnull String path, @Nonnull String... searchPaths) {
1230        return this.findURI(path, Location.ALL, searchPaths);
1231    }
1232
1233    /**
1234     * Search for a file or JAR resource by name and return the
1235     * {@link java.net.URI} for that file. Search order is defined by
1236     * {@link #findURI(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1237     *
1238     * @param path      The relative path of the file or resource
1239     * @param locations The types of locations to limit the search to
1240     * @return The URI or null
1241     * @see #findURI(java.lang.String)
1242     * @see #findURI(java.lang.String, java.lang.String...)
1243     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location,
1244     * java.lang.String...)
1245     */
1246    public URI findURI(@Nonnull String path, @Nonnull Location locations) {
1247        return this.findURI(path, locations, new String[]{});
1248    }
1249
1250    /**
1251     * Search for a file or JAR resource by name and return the
1252     * {@link java.net.URI} for that file.
1253     * <p>
1254     * Search order is:
1255     * <ol>
1256     * <li>For any provided searchPaths, iterate over the searchPaths by
1257     * prepending each searchPath to the path and following the following search
1258     * order:<ol>
1259     * <li>As a {@link java.io.File} in the user preferences directory</li>
1260     * <li>As a File in the current working directory (usually, but not always
1261     * the JMRI distribution directory)</li>
1262     * <li>As a File in the JMRI distribution directory</li>
1263     * <li>As a resource in jmri.jar</li>
1264     * </ol></li>
1265     * <li>If the file or resource has not been found in the searchPaths, search
1266     * in the four locations listed without prepending any path</li>
1267     * <li>As a File with an absolute path</li>
1268     * </ol>
1269     * <p>
1270     * The <code>locations</code> parameter limits the above logic by limiting
1271     * the location searched.
1272     * <ol>
1273     * <li>{@link Location#ALL} will not place any limits on the search</li>
1274     * <li>{@link Location#NONE} effectively requires that <code>path</code> be
1275     * a portable pathname</li>
1276     * <li>{@link Location#INSTALLED} limits the search to the
1277     * {@link FileUtil#PROGRAM} directory and JARs in the class path</li>
1278     * <li>{@link Location#USER} limits the search to the
1279     * {@link FileUtil#PREFERENCES}, {@link FileUtil#PROFILE}, and
1280     * {@link FileUtil#SETTINGS} directories (in that order)</li>
1281     * </ol>
1282     *
1283     * @param path        The relative path of the file or resource
1284     * @param locations   The types of locations to limit the search to
1285     * @param searchPaths a list of paths to search for the path in
1286     * @return The URI or null
1287     * @see #findURI(java.lang.String)
1288     * @see #findURI(java.lang.String, jmri.util.FileUtil.Location)
1289     * @see #findURI(java.lang.String, java.lang.String...)
1290     */
1291    public URI findURI(@Nonnull String path, @Nonnull Location locations, @Nonnull String... searchPaths) {
1292        if (log.isDebugEnabled()) { // avoid the Arrays.toString call unless debugging
1293            log.debug("Attempting to find {} in {}", path, Arrays.toString(searchPaths));
1294        }
1295        if (this.isPortableFilename(path)) {
1296            try {
1297                return this.findExternalFilename(path);
1298            } catch (NullPointerException ex) {
1299                // do nothing
1300            }
1301        }
1302        URI resource = null;
1303        for (String searchPath : searchPaths) {
1304            resource = this.findURI(searchPath + File.separator + path);
1305            if (resource != null) {
1306                return resource;
1307            }
1308        }
1309        File file;
1310        if (locations == Location.ALL || locations == Location.USER) {
1311            // attempt to return path from preferences directory
1312            file = new File(this.getUserFilesPath(), path);
1313            if (file.exists()) {
1314                return file.toURI();
1315            }
1316            // attempt to return path from profile directory
1317            file = new File(this.getProfilePath(), path);
1318            if (file.exists()) {
1319                return file.toURI();
1320            }
1321            // attempt to return path from preferences directory
1322            file = new File(this.getPreferencesPath(), path);
1323            if (file.exists()) {
1324                return file.toURI();
1325            }
1326        }
1327        if (locations == Location.ALL || locations == Location.INSTALLED) {
1328            // attempt to return path from current working directory
1329            file = new File(path);
1330            if (file.exists()) {
1331                return file.toURI();
1332            }
1333            // attempt to return path from JMRI distribution directory
1334            file = new File(this.getProgramPath() + path);
1335            if (file.exists()) {
1336                return file.toURI();
1337            }
1338        }
1339        if (locations == Location.ALL || locations == Location.INSTALLED) {
1340            // return path if in jmri.jar or null
1341            // The ClassLoader needs paths to use /
1342            path = path.replace(File.separatorChar, '/');
1343            URL url = FileUtilSupport.class.getClassLoader().getResource(path);
1344            if (url == null) {
1345                url = FileUtilSupport.class.getResource(path);
1346                if (url == null) {
1347                    log.debug("{} not found in classpath", path);
1348                }
1349            }
1350            try {
1351                resource = (url != null) ? url.toURI() : null;
1352            } catch (URISyntaxException ex) {
1353                log.warn("Unable to get URI for {}", path, ex);
1354            }
1355        }
1356        // if a resource has not been found and path is absolute and exists
1357        // return it
1358        if (resource == null) {
1359            file = new File(path);
1360            if (file.isAbsolute() && file.exists()) {
1361                return file.toURI();
1362            }
1363        }
1364        return resource;
1365    }
1366
1367    /**
1368     * Search for a file or JAR resource by name and return the
1369     * {@link java.net.URL} for that file. Search order is defined by
1370     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1371     * No limits are placed on search locations.
1372     *
1373     * @param path The relative path of the file or resource.
1374     * @return The URL or null.
1375     * @see #findURL(java.lang.String, java.lang.String...)
1376     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location)
1377     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location,
1378     * java.lang.String...)
1379     */
1380    public URL findURL(@Nonnull String path) {
1381        return this.findURL(path, new String[]{});
1382    }
1383
1384    /**
1385     * Search for a file or JAR resource by name and return the
1386     * {@link java.net.URL} for that file. Search order is defined by
1387     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1388     * No limits are placed on search locations.
1389     *
1390     * @param path        The relative path of the file or resource
1391     * @param searchPaths a list of paths to search for the path in
1392     * @return The URL or null
1393     * @see #findURL(java.lang.String)
1394     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location)
1395     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location,
1396     * java.lang.String...)
1397     */
1398    public URL findURL(@Nonnull String path, @Nonnull String... searchPaths) {
1399        return this.findURL(path, Location.ALL, searchPaths);
1400    }
1401
1402    /**
1403     * Search for a file or JAR resource by name and return the
1404     * {@link java.net.URL} for that file. Search order is defined by
1405     * {@link #findURL(java.lang.String, jmri.util.FileUtil.Location, java.lang.String...)}.
1406     *
1407     * @param path      The relative path of the file or resource
1408     * @param locations The types of locations to limit the search to
1409     * @return The URL or null
1410     * @see #findURL(java.lang.String)
1411     * @see #findURL(java.lang.String, java.lang.String...)
1412     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location,
1413     * java.lang.String...)
1414     */
1415    public URL findURL(@Nonnull String path, Location locations) {
1416        return this.findURL(path, locations, new String[]{});
1417    }
1418
1419    /**
1420     * Search for a file or JAR resource by name and return the
1421     * {@link java.net.URL} for that file.
1422     * <p>
1423     * Search order is:
1424     * <ol><li>For any provided searchPaths, iterate over the searchPaths by
1425     * prepending each searchPath to the path and following the following search
1426     * order:
1427     * <ol><li>As a {@link java.io.File} in the user preferences directory</li>
1428     * <li>As a File in the current working directory (usually, but not always
1429     * the JMRI distribution directory)</li> <li>As a File in the JMRI
1430     * distribution directory</li> <li>As a resource in jmri.jar</li></ol></li>
1431     * <li>If the file or resource has not been found in the searchPaths, search
1432     * in the four locations listed without prepending any path</li></ol>
1433     * <p>
1434     * The <code>locations</code> parameter limits the above logic by limiting
1435     * the location searched.
1436     * <ol><li>{@link Location#ALL} will not place any limits on the
1437     * search</li><li>{@link Location#NONE} effectively requires that
1438     * <code>path</code> be a portable
1439     * pathname</li><li>{@link Location#INSTALLED} limits the search to the
1440     * {@link FileUtil#PROGRAM} directory and JARs in the class
1441     * path</li><li>{@link Location#USER} limits the search to the
1442     * {@link FileUtil#PROFILE} directory</li></ol>
1443     *
1444     * @param path        The relative path of the file or resource
1445     * @param locations   The types of locations to limit the search to
1446     * @param searchPaths a list of paths to search for the path in
1447     * @return The URL or null
1448     * @see #findURL(java.lang.String)
1449     * @see #findURL(java.lang.String, jmri.util.FileUtil.Location)
1450     * @see #findURL(java.lang.String, java.lang.String...)
1451     */
1452    public URL findURL(@Nonnull String path, @Nonnull Location locations, @Nonnull String... searchPaths) {
1453        URI file = this.findURI(path, locations, searchPaths);
1454        if (file != null) {
1455            try {
1456                return file.toURL();
1457            } catch (MalformedURLException ex) {
1458                log.error("findURL MalformedURLException", ex);
1459            }
1460        }
1461        return null;
1462    }
1463
1464    /**
1465     * Return the {@link java.net.URI} for a given URL
1466     *
1467     * @param url the URL
1468     * @return a URI or null if the conversion would have caused a
1469     *         {@link java.net.URISyntaxException}
1470     */
1471    public URI urlToURI(@Nonnull URL url) {
1472        try {
1473            return url.toURI();
1474        } catch (URISyntaxException ex) {
1475            log.error("Unable to get URI from URL", ex);
1476            return null;
1477        }
1478    }
1479
1480    /**
1481     * Return the {@link java.net.URL} for a given {@link java.io.File}. This
1482     * method catches a {@link java.net.MalformedURLException} and returns null
1483     * in its place, since we really do not expect a File object to ever give a
1484     * malformed URL. This method exists solely so implementing classes do not
1485     * need to catch that exception.
1486     *
1487     * @param file The File to convert.
1488     * @return a URL or null if the conversion would have caused a
1489     *         MalformedURLException
1490     */
1491    public URL fileToURL(@Nonnull File file) {
1492        try {
1493            return file.toURI().toURL();
1494        } catch (MalformedURLException ex) {
1495            log.error("Unable to get URL from file", ex);
1496            return null;
1497        }
1498    }
1499
1500    /**
1501     * Get the JMRI distribution jar file.
1502     *
1503     * @return the JAR file containing the JMRI library or null if not running
1504     *         from a JAR file
1505     */
1506    public JarFile getJmriJarFile() {
1507        if (jarPath == null) {
1508            CodeSource sc = FileUtilSupport.class.getProtectionDomain().getCodeSource();
1509            if (sc != null) {
1510                jarPath = sc.getLocation().toString();
1511                if (jarPath.startsWith("jar:file:")) {
1512                    // 9 = length of jar:file:
1513                    jarPath = jarPath.substring(9, jarPath.lastIndexOf("!"));
1514                } else {
1515                    log.info("Running from classes not in jar file.");
1516                    jarPath = ""; // set to empty String to bypass search
1517                    return null;
1518                }
1519                log.debug("jmri.jar path is {}", jarPath);
1520            }
1521            if (jarPath == null) {
1522                log.error("Unable to locate jmri.jar");
1523                jarPath = ""; // set to empty String to bypass search
1524                return null;
1525            }
1526        }
1527        if (!jarPath.isEmpty()) {
1528            try {
1529                return new JarFile(jarPath);
1530            } catch (IOException ex) {
1531                log.error("Unable to open jmri.jar", ex);
1532                return null;
1533            }
1534        }
1535        return null;
1536    }
1537
1538    /**
1539     * Read a text file into a String.
1540     *
1541     * @param file The text file.
1542     * @return The contents of the file.
1543     * @throws java.io.IOException if the file cannot be read
1544     */
1545    public String readFile(@Nonnull File file) throws IOException {
1546        return this.readURL(this.fileToURL(file));
1547    }
1548
1549    /**
1550     * Read a text URL into a String.
1551     *
1552     * @param url The text URL.
1553     * @return The contents of the file.
1554     * @throws java.io.IOException if the URL cannot be read
1555     */
1556    public String readURL(@Nonnull URL url) throws IOException {
1557        try {
1558            try (InputStreamReader in = new InputStreamReader(url.openStream(), StandardCharsets.UTF_8);
1559                    BufferedReader reader = new BufferedReader(in)) {
1560                return reader.lines().collect(Collectors.joining("\n")); // NOI18N
1561            }
1562        } catch (NullPointerException ex) {
1563            return null;
1564        }
1565    }
1566
1567    /**
1568     * Replaces most non-alphanumeric characters in name with an underscore.
1569     *
1570     * @param name The filename to be sanitized.
1571     * @return The sanitized filename.
1572     */
1573    @Nonnull
1574    public String sanitizeFilename(@Nonnull String name) {
1575        name = name.trim().replaceAll(" ", "_").replaceAll("[.]+", ".");
1576        StringBuilder filename = new StringBuilder();
1577        for (char c : name.toCharArray()) {
1578            if (c == '.' || Character.isJavaIdentifierPart(c)) {
1579                filename.append(c);
1580            }
1581        }
1582        return filename.toString();
1583    }
1584
1585    /**
1586     * Create a directory if required. Any parent directories will also be
1587     * created.
1588     *
1589     * @param path directory to create
1590     */
1591    public void createDirectory(@Nonnull String path) {
1592        this.createDirectory(new File(path));
1593    }
1594
1595    /**
1596     * Create a directory if required. Any parent directories will also be
1597     * created.
1598     *
1599     * @param dir directory to create
1600     */
1601    public void createDirectory(@Nonnull File dir) {
1602        if (!dir.exists()) {
1603            log.debug("Creating directory: {}", dir);
1604            if (!dir.mkdirs()) {
1605                log.error("Failed to create directory: {}", dir);
1606            }
1607        }
1608    }
1609
1610    /**
1611     * Recursively delete a path. It is recommended to use
1612     * {@link java.nio.file.Files#delete(java.nio.file.Path)} or
1613     * {@link java.nio.file.Files#deleteIfExists(java.nio.file.Path)} for files.
1614     *
1615     * @param path path to delete
1616     * @return true if path was deleted, false otherwise
1617     */
1618    public boolean delete(@Nonnull File path) {
1619        if (path.isDirectory()) {
1620            File[] files = path.listFiles();
1621            if (files != null) {
1622                for (File file : files) {
1623                    this.delete(file);
1624                }
1625            }
1626        }
1627        return path.delete();
1628    }
1629
1630    /**
1631     * Copy a file or directory. It is recommended to use
1632     * {@link java.nio.file.Files#copy(java.nio.file.Path, java.io.OutputStream)}
1633     * for files.
1634     *
1635     * @param source the file or directory to copy
1636     * @param dest   must be the file or directory, not the containing directory
1637     * @throws java.io.IOException if file cannot be copied
1638     */
1639    public void copy(@Nonnull File source, @Nonnull File dest) throws IOException {
1640        if (!source.exists()) {
1641            log.error("Attempting to copy non-existant file: {}", source);
1642            return;
1643        }
1644        if (!dest.exists()) {
1645            if (source.isDirectory()) {
1646                boolean ok = dest.mkdirs();
1647                if (!ok) {
1648                    throw new IOException("Could not use mkdirs to create destination directory");
1649                }
1650            } else {
1651                boolean ok = dest.createNewFile();
1652                if (!ok) {
1653                    throw new IOException("Could not create destination file");
1654                }
1655            }
1656        }
1657        Path srcPath = source.toPath();
1658        Path dstPath = dest.toPath();
1659        if (source.isDirectory()) {
1660            Files.walkFileTree(srcPath, new SimpleFileVisitor<Path>() {
1661                @Override
1662                public FileVisitResult preVisitDirectory(final Path dir,
1663                        final BasicFileAttributes attrs) throws IOException {
1664                    Files.createDirectories(dstPath.resolve(srcPath.relativize(dir)));
1665                    return FileVisitResult.CONTINUE;
1666                }
1667
1668                @Override
1669                public FileVisitResult visitFile(final Path file,
1670                        final BasicFileAttributes attrs) throws IOException {
1671                    Files.copy(file, dstPath.resolve(srcPath.relativize(file)), StandardCopyOption.REPLACE_EXISTING);
1672                    return FileVisitResult.CONTINUE;
1673                }
1674            });
1675        } else {
1676            Files.copy(source.toPath(), dest.toPath(), StandardCopyOption.REPLACE_EXISTING);
1677        }
1678    }
1679
1680    /**
1681     * Simple helper method to just append a text string to the end of the given
1682     * filename. The file will be created if it does not exist.
1683     *
1684     * @param file File to append text to
1685     * @param text Text to append
1686     * @throws java.io.IOException if file cannot be written to
1687     */
1688    public void appendTextToFile(@Nonnull File file, @Nonnull String text) throws IOException {
1689        try (PrintWriter pw = new PrintWriter(new OutputStreamWriter(new FileOutputStream(file, true), StandardCharsets.UTF_8))) {
1690            pw.println(text);
1691        }
1692    }
1693
1694    /**
1695     * Backup a file. The backup is in the same location as the original file,
1696     * has the extension <code>.bak</code> appended to the file name, and up to
1697     * four revisions are retained. The lowest numbered revision is the most
1698     * recent.
1699     *
1700     * @param file the file to backup
1701     * @throws java.io.IOException if a backup cannot be created
1702     */
1703    public void backup(@Nonnull File file) throws IOException {
1704        this.rotate(file, 4, "bak");
1705    }
1706
1707    /**
1708     * Rotate a file and its backups, retaining only a set number of backups.
1709     *
1710     * @param file      the file to rotate
1711     * @param max       maximum number of backups to retain
1712     * @param extension The extension to use for the rotations. If null or an
1713     *                  empty string, the rotation number is used as the
1714     *                  extension.
1715     * @throws java.io.IOException      if a backup cannot be created
1716     * @throws IllegalArgumentException if max is less than one
1717     * @see #backup(java.io.File)
1718     */
1719    public void rotate(@Nonnull File file, int max, @CheckForNull String extension) throws IOException {
1720        if (max < 1) {
1721            throw new IllegalArgumentException();
1722        }
1723        String name = file.getName();
1724        if (extension != null) {
1725            if (extension.length() > 0 && !extension.startsWith(".")) {
1726                extension = "." + extension;
1727            }
1728        } else {
1729            extension = "";
1730        }
1731        File dir = file.getParentFile();
1732        File source;
1733        int i = max;
1734        while (i > 1) {
1735            source = new File(dir, name + "." + (i - 1) + extension);
1736            if (source.exists()) {
1737                this.copy(source, new File(dir, name + "." + i + extension));
1738            }
1739            i--;
1740        }
1741        this.copy(file, new File(dir, name + "." + i + extension));
1742    }
1743
1744    /**
1745     * Get the default instance of a FileUtilSupport object.
1746     * <p>
1747     * Unlike most implementations of getDefault(), this does not return an
1748     * object held by {@link jmri.InstanceManager} due to the need for this
1749     * default instance to be available prior to the creation of an
1750     * InstanceManager.
1751     *
1752     * @return the default FileUtilSupport instance, creating it if necessary
1753     */
1754    public static FileUtilSupport getDefault() {
1755        if (FileUtilSupport.defaultInstance == null) {
1756            FileUtilSupport.defaultInstance = new FileUtilSupport();
1757        }
1758        return FileUtilSupport.defaultInstance;
1759    }
1760
1761    /**
1762     * Get the canonical path for a portable path. There are nine cases:
1763     * <ul>
1764     * <li>Starts with "resource:", treat the rest as a pathname relative to the
1765     * program directory (deprecated; see "program:" below)</li>
1766     * <li>Starts with "program:", treat the rest as a relative pathname below
1767     * the program directory</li>
1768     * <li>Starts with "preference:", treat the rest as a relative path below
1769     * the user's files directory</li>
1770     * <li>Starts with "settings:", treat the rest as a relative path below the
1771     * JMRI system preferences directory</li>
1772     * <li>Starts with "home:", treat the rest as a relative path below the
1773     * user.home directory</li>
1774     * <li>Starts with "file:", treat the rest as a relative path below the
1775     * resource directory in the preferences directory (deprecated; see
1776     * "preference:" above)</li>
1777     * <li>Starts with "profile:", treat the rest as a relative path below the
1778     * profile directory as specified in the
1779     * active{@link jmri.profile.Profile}</li>
1780     * <li>Starts with "scripts:", treat the rest as a relative path below the
1781     * scripts directory</li>
1782     * <li>Otherwise, treat the name as a relative path below the program
1783     * directory</li>
1784     * </ul>
1785     * In any case, absolute pathnames will work.
1786     *
1787     * @param path The name string, possibly starting with file:, home:,
1788     *             profile:, program:, preference:, scripts:, settings, or
1789     *             resource:
1790     * @return Canonical path to use, or null if one cannot be found.
1791     * @since 2.7.2
1792     */
1793    private String pathFromPortablePath(@CheckForNull Profile profile, @Nonnull String path) {
1794        // As this method is called in Log4J setup, should not
1795        // contain standard logging statements.
1796        if (path.startsWith(PROGRAM)) {
1797            if (new File(path.substring(PROGRAM.length())).isAbsolute()) {
1798                path = path.substring(PROGRAM.length());
1799            } else {
1800                path = path.replaceFirst(PROGRAM, Matcher.quoteReplacement(this.getProgramPath()));
1801            }
1802        } else if (path.startsWith(PREFERENCES)) {
1803            if (new File(path.substring(PREFERENCES.length())).isAbsolute()) {
1804                path = path.substring(PREFERENCES.length());
1805            } else {
1806                path = path.replaceFirst(PREFERENCES, Matcher.quoteReplacement(this.getUserFilesPath(profile)));
1807            }
1808        } else if (path.startsWith(PROFILE)) {
1809            if (new File(path.substring(PROFILE.length())).isAbsolute()) {
1810                path = path.substring(PROFILE.length());
1811            } else {
1812                path = path.replaceFirst(PROFILE, Matcher.quoteReplacement(this.getProfilePath(profile)));
1813            }
1814        } else if (path.startsWith(SCRIPTS)) {
1815            if (new File(path.substring(SCRIPTS.length())).isAbsolute()) {
1816                path = path.substring(SCRIPTS.length());
1817            } else {
1818                path = path.replaceFirst(SCRIPTS, Matcher.quoteReplacement(this.getScriptsPath(profile)));
1819            }
1820        } else if (path.startsWith(SETTINGS)) {
1821            if (new File(path.substring(SETTINGS.length())).isAbsolute()) {
1822                path = path.substring(SETTINGS.length());
1823            } else {
1824                path = path.replaceFirst(SETTINGS, Matcher.quoteReplacement(this.getPreferencesPath()));
1825            }
1826        } else if (path.startsWith(HOME)) {
1827            if (new File(path.substring(HOME.length())).isAbsolute()) {
1828                path = path.substring(HOME.length());
1829            } else {
1830                path = path.replaceFirst(HOME, Matcher.quoteReplacement(this.getHomePath()));
1831            }
1832        } else if (!new File(path).isAbsolute()) {
1833            return null;
1834        }
1835        try {
1836            // if path cannot be converted into a canonical path, return null
1837            return new File(path.replace(SEPARATOR, File.separatorChar)).getCanonicalPath();
1838        } catch (IOException ex) {
1839            System.err.println("Cannot convert " + path + " into a usable filename. " + ex.getMessage());
1840            return null;
1841        }
1842    }
1843
1844}