    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 */
    017
    018package org.apache.commons.io;
    019
    020import java.util.Arrays;
    021import java.util.Locale;
    022import java.util.Objects;
    023
    024/**
    025 * Abstracts an OS' file system details, currently supporting the single use case of converting a file name String to a
    026 * legal file name with {@link #toLegalFileName(String, char)}.
    027 * <p>
    028 * The starting point of any operation is {@link #getCurrent()} which gets you the enum for the file system that matches
    029 * the OS hosting the running JVM.
    030 * </p>
    031 *
    032 * @since 2.7
    033 */
    034public enum FileSystem {
    035
    036    /**
    037     * Generic file system.
    038     */
    039    GENERIC(4096, false, false, Integer.MAX_VALUE, Integer.MAX_VALUE, new int[] { 0 }, new String[] {}, false, false, '/'),
    040
    041    /**
    042     * Linux file system.
    043     */
    044    LINUX(8192, true, true, 255, 4096, new int[] {
    045            // KEEP THIS ARRAY SORTED!
    046            // @formatter:off
    047            // ASCII NUL
    048            0,
    049             '/'
    050            // @formatter:on
    051    }, new String[] {}, false, false, '/'),
    052
    053    /**
    054     * MacOS file system.
    055     */
    056    MAC_OSX(4096, true, true, 255, 1024, new int[] {
    057            // KEEP THIS ARRAY SORTED!
    058            // @formatter:off
    059            // ASCII NUL
    060            0,
    061            '/',
    062             ':'
    063            // @formatter:on
    064    }, new String[] {}, false, false, '/'),
    065
    066    /**
    067     * Windows file system.
    068     * <p>
    069     * The reserved characters are defined in the
    070     * <a href="https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file">Naming Conventions
    071     * (microsoft.com)</a>.
    072     * </p>
    073     *
    074     * @see <a href="https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file">Naming Conventions
    075     *      (microsoft.com)</a>
    076     * @see <a href="https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea#consoles">
    077     *      CreateFileA function - Consoles (microsoft.com)</a>
    078     */
    079    // @formatter:off
    080    WINDOWS(4096, false, true,
    081            255, 32000, // KEEP THIS ARRAY SORTED!
    082            new int[] {
    083                    // KEEP THIS ARRAY SORTED!
    084                    // ASCII NUL
    085                    0,
    086                    // 1-31 may be allowed in file streams
    087                    1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28,
    088                    29, 30, 31,
    089                    '"', '*', '/', ':', '<', '>', '?', '\\', '|'
    090            }, new String[] {
    091                    "AUX",
    092                    "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
    093                    "COM\u00b2", "COM\u00b3", "COM\u00b9", // Superscript 2 3 1 in that order
    094                    "CON", "CONIN$", "CONOUT$",
    095                    "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
    096                    "LPT\u00b2", "LPT\u00b3", "LPT\u00b9", // Superscript 2 3 1 in that order
    097                    "NUL", "PRN"
    098            }, true, true, '\\');
    099    // @formatter:on
    100
    101    /**
    102     * <p>
    103     * Is {@code true} if this is Linux.
    104     * </p>
    105     * <p>
    106     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
    107     * </p>
    108     */
    109    private static final boolean IS_OS_LINUX = getOsMatchesName("Linux");
    110
    111    /**
    112     * <p>
    113     * Is {@code true} if this is Mac.
    114     * </p>
    115     * <p>
    116     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
    117     * </p>
    118     */
    119    private static final boolean IS_OS_MAC = getOsMatchesName("Mac");
    120
    121    /**
    122     * The prefix String for all Windows OS.
    123     */
    124    private static final String OS_NAME_WINDOWS_PREFIX = "Windows";
    125
    126    /**
    127     * <p>
    128     * Is {@code true} if this is Windows.
    129     * </p>
    130     * <p>
    131     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
    132     * </p>
    133     */
    134    private static final boolean IS_OS_WINDOWS = getOsMatchesName(OS_NAME_WINDOWS_PREFIX);
    135
    136    /**
    137     * The current FileSystem.
    138     */
    139    private static final FileSystem CURRENT = current();
    140
    141    /**
    142     * Gets the current file system.
    143     *
    144     * @return the current file system
    145     */
    146    private static FileSystem current() {
    147        if (IS_OS_LINUX) {
    148            return LINUX;
    149        }
    150        if (IS_OS_MAC) {
    151            return MAC_OSX;
    152        }
    153        if (IS_OS_WINDOWS) {
    154            return WINDOWS;
    155        }
    156        return GENERIC;
    157    }
    158
    159    /**
    160     * Gets the current file system.
    161     *
    162     * @return the current file system
    163     */
    164    public static FileSystem getCurrent() {
    165        return CURRENT;
    166    }
    167
    168    /**
    169     * Decides if the operating system matches.
    170     *
    171     * @param osNamePrefix
    172     *            the prefix for the os name
    173     * @return true if matches, or false if not or can't determine
    174     */
    175    private static boolean getOsMatchesName(final String osNamePrefix) {
    176        return isOsNameMatch(getSystemProperty("os.name"), osNamePrefix);
    177    }
    178
    179    /**
    180     * <p>
    181     * Gets a System property, defaulting to {@code null} if the property cannot be read.
    182     * </p>
    183     * <p>
    184     * If a {@link SecurityException} is caught, the return value is {@code null} and a message is written to
    185     * {@code System.err}.
    186     * </p>
    187     *
    188     * @param property
    189     *            the system property name
    190     * @return the system property value or {@code null} if a security problem occurs
    191     */
    192    private static String getSystemProperty(final String property) {
    193        try {
    194            return System.getProperty(property);
    195        } catch (final SecurityException ex) {
    196            // we are not allowed to look at this property
    197            System.err.println("Caught a SecurityException reading the system property '" + property
    198                    + "'; the SystemUtils property value will default to null.");
    199            return null;
    200        }
    201    }
    202
    203    /**
    204     * Copied from Apache Commons Lang CharSequenceUtils.
    205     *
    206     * Returns the index within {@code cs} of the first occurrence of the
    207     * specified character, starting the search at the specified index.
    208     * <p>
    209     * If a character with value {@code searchChar} occurs in the
    210     * character sequence represented by the {@code cs}
    211     * object at an index no smaller than {@code start}, then
    212     * the index of the first such occurrence is returned. For values
    213     * of {@code searchChar} in the range from 0 to 0xFFFF (inclusive),
    214     * this is the smallest value <em>k</em> such that:
    215     * </p>
    216     * <blockquote><pre>
    217     * (this.charAt(<em>k</em>) == searchChar) &amp;&amp; (<em>k</em> &gt;= start)
    218     * </pre></blockquote>
    219     * is true. For other values of {@code searchChar}, it is the
    220     * smallest value <em>k</em> such that:
    221     * <blockquote><pre>
    222     * (this.codePointAt(<em>k</em>) == searchChar) &amp;&amp; (<em>k</em> &gt;= start)
    223     * </pre></blockquote>
    224     * <p>
    225     * is true. In either case, if no such character occurs in {@code cs}
    226     * at or after position {@code start}, then
    227     * {@code -1} is returned.
    228     * </p>
    229     * <p>
    230     * There is no restriction on the value of {@code start}. If it
    231     * is negative, it has the same effect as if it were zero: the entire
    232     * {@link CharSequence} may be searched. If it is greater than
    233     * the length of {@code cs}, it has the same effect as if it were
    234     * equal to the length of {@code cs}: {@code -1} is returned.
    235     * </p>
    236     * <p>All indices are specified in {@code char} values
    237     * (Unicode code units).
    238     * </p>
    239     *
    240     * @param cs  the {@link CharSequence} to be processed, not null
    241     * @param searchChar  the char to be searched for
    242     * @param start  the start index, negative starts at the string start
    243     * @return the index where the search char was found, -1 if not found
    244     * @since 3.6 updated to behave more like {@link String}
    245     */
    246    private static int indexOf(final CharSequence cs, final int searchChar, int start) {
    247        if (cs instanceof String) {
    248            return ((String) cs).indexOf(searchChar, start);
    249        }
    250        final int sz = cs.length();
    251        if (start < 0) {
    252            start = 0;
    253        }
    254        if (searchChar < Character.MIN_SUPPLEMENTARY_CODE_POINT) {
    255            for (int i = start; i < sz; i++) {
    256                if (cs.charAt(i) == searchChar) {
    257                    return i;
    258                }
    259            }
    260            return -1;
    261        }
    262        //supplementary characters (LANG1300)
    263        if (searchChar <= Character.MAX_CODE_POINT) {
    264            final char[] chars = Character.toChars(searchChar);
    265            for (int i = start; i < sz - 1; i++) {
    266                final char high = cs.charAt(i);
    267                final char low = cs.charAt(i + 1);
    268                if (high == chars[0] && low == chars[1]) {
    269                    return i;
    270                }
    271            }
    272        }
    273        return -1;
    274    }
    275
    276    /**
    277     * Decides if the operating system matches.
    278     * <p>
    279     * This method is package private instead of private to support unit test invocation.
    280     * </p>
    281     *
    282     * @param osName
    283     *            the actual OS name
    284     * @param osNamePrefix
    285     *            the prefix for the expected OS name
    286     * @return true if matches, or false if not or can't determine
    287     */
    288    private static boolean isOsNameMatch(final String osName, final String osNamePrefix) {
    289        if (osName == null) {
    290            return false;
    291        }
    292        return osName.toUpperCase(Locale.ROOT).startsWith(osNamePrefix.toUpperCase(Locale.ROOT));
    293    }
    294
    295    /**
    296     * Null-safe replace.
    297     *
    298     * @param path the path to be changed, null ignored.
    299     * @param oldChar the old character.
    300     * @param newChar the new character.
    301     * @return the new path.
    302     */
    303    private static String replace(final String path, final char oldChar, final char newChar) {
    304        return path == null ? null : path.replace(oldChar, newChar);
    305    }
    306
    307    private final int blockSize;
    308    private final boolean casePreserving;
    309    private final boolean caseSensitive;
    310    private final int[] illegalFileNameChars;
    311    private final int maxFileNameLength;
    312    private final int maxPathLength;
    313    private final String[] reservedFileNames;
    314    private final boolean reservedFileNamesExtensions;
    315    private final boolean supportsDriveLetter;
    316    private final char nameSeparator;
    317    private final char nameSeparatorOther;
    318
    319    /**
    320     * Constructs a new instance.
    321     *
    322     * @param blockSize file allocation block size in bytes.
    323     * @param caseSensitive Whether this file system is case-sensitive.
    324     * @param casePreserving Whether this file system is case-preserving.
    325     * @param maxFileLength The maximum length for file names. The file name does not include folders.
    326     * @param maxPathLength The maximum length of the path to a file. This can include folders.
    327     * @param illegalFileNameChars Illegal characters for this file system.
    328     * @param reservedFileNames The reserved file names.
    329     * @param reservedFileNamesExtensions TODO
    330     * @param supportsDriveLetter Whether this file system support driver letters.
    331     * @param nameSeparator The name separator, '\\' on Windows, '/' on Linux.
    332     */
    333    FileSystem(final int blockSize, final boolean caseSensitive, final boolean casePreserving,
    334        final int maxFileLength, final int maxPathLength, final int[] illegalFileNameChars,
    335        final String[] reservedFileNames, final boolean reservedFileNamesExtensions, final boolean supportsDriveLetter, final char nameSeparator) {
    336        this.blockSize = blockSize;
    337        this.maxFileNameLength = maxFileLength;
    338        this.maxPathLength = maxPathLength;
    339        this.illegalFileNameChars = Objects.requireNonNull(illegalFileNameChars, "illegalFileNameChars");
    340        this.reservedFileNames = Objects.requireNonNull(reservedFileNames, "reservedFileNames");
    341        //Arrays.sort(this.reservedFileNames);
    342        this.reservedFileNamesExtensions = reservedFileNamesExtensions;
    343        this.caseSensitive = caseSensitive;
    344        this.casePreserving = casePreserving;
    345        this.supportsDriveLetter = supportsDriveLetter;
    346        this.nameSeparator = nameSeparator;
    347        this.nameSeparatorOther = FilenameUtils.flipSeparator(nameSeparator);
    348    }
    349
    350    /**
    351     * Gets the file allocation block size in bytes.
    352     * @return the file allocation block size in bytes.
    353     *
    354     * @since 2.12.0
    355     */
    356    public int getBlockSize() {
    357        return blockSize;
    358    }
    359
    360    /**
    361     * Gets a cloned copy of the illegal characters for this file system.
    362     *
    363     * @return the illegal characters for this file system.
    364     */
    365    public char[] getIllegalFileNameChars() {
    366        final char[] chars = new char[illegalFileNameChars.length];
    367        for (int i = 0; i < illegalFileNameChars.length; i++) {
    368            chars[i] = (char) illegalFileNameChars[i];
    369        }
    370        return chars;
    371    }
    372
    373    /**
    374     * Gets a cloned copy of the illegal code points for this file system.
    375     *
    376     * @return the illegal code points for this file system.
    377     * @since 2.12.0
    378     */
    379    public int[] getIllegalFileNameCodePoints() {
    380        return this.illegalFileNameChars.clone();
    381    }
    382
    383    /**
    384     * Gets the maximum length for file names. The file name does not include folders.
    385     *
    386     * @return the maximum length for file names.
    387     */
    388    public int getMaxFileNameLength() {
    389        return maxFileNameLength;
    390    }
    391
    392    /**
    393     * Gets the maximum length of the path to a file. This can include folders.
    394     *
    395     * @return the maximum length of the path to a file.
    396     */
    397    public int getMaxPathLength() {
    398        return maxPathLength;
    399    }
    400
    401    /**
    402     * Gets the name separator, '\\' on Windows, '/' on Linux.
    403     *
    404     * @return '\\' on Windows, '/' on Linux.
    405     *
    406     * @since 2.12.0
    407     */
    408    public char getNameSeparator() {
    409        return nameSeparator;
    410    }
    411
    412    /**
    413     * Gets a cloned copy of the reserved file names.
    414     *
    415     * @return the reserved file names.
    416     */
    417    public String[] getReservedFileNames() {
    418        return reservedFileNames.clone();
    419    }
    420
    421    /**
    422     * Tests whether this file system preserves case.
    423     *
    424     * @return Whether this file system preserves case.
    425     */
    426    public boolean isCasePreserving() {
    427        return casePreserving;
    428    }
    429
    430    /**
    431     * Tests whether this file system is case-sensitive.
    432     *
    433     * @return Whether this file system is case-sensitive.
    434     */
    435    public boolean isCaseSensitive() {
    436        return caseSensitive;
    437    }
    438
    439    /**
    440     * Tests if the given character is illegal in a file name, {@code false} otherwise.
    441     *
    442     * @param c
    443     *            the character to test
    444     * @return {@code true} if the given character is illegal in a file name, {@code false} otherwise.
    445     */
    446    private boolean isIllegalFileNameChar(final int c) {
    447        return Arrays.binarySearch(illegalFileNameChars, c) >= 0;
    448    }
    449
    450    /**
    451     * Tests if a candidate file name (without a path) such as {@code "filename.ext"} or {@code "filename"} is a
    452     * potentially legal file name. If the file name length exceeds {@link #getMaxFileNameLength()}, or if it contains
    453     * an illegal character then the check fails.
    454     *
    455     * @param candidate
    456     *            a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}
    457     * @return {@code true} if the candidate name is legal
    458     */
    459    public boolean isLegalFileName(final CharSequence candidate) {
    460        if (candidate == null || candidate.length() == 0 || candidate.length() > maxFileNameLength) {
    461            return false;
    462        }
    463        if (isReservedFileName(candidate)) {
    464            return false;
    465        }
    466        return candidate.chars().noneMatch(this::isIllegalFileNameChar);
    467    }
    468
    469    /**
    470     * Tests whether the given string is a reserved file name.
    471     *
    472     * @param candidate
    473     *            the string to test
    474     * @return {@code true} if the given string is a reserved file name.
    475     */
    476    public boolean isReservedFileName(final CharSequence candidate) {
    477        final CharSequence test = reservedFileNamesExtensions ? trimExtension(candidate) : candidate;
    478        return Arrays.binarySearch(reservedFileNames, test) >= 0;
    479    }
    480
    481    /**
    482     * Converts all separators to the Windows separator of backslash.
    483     *
    484     * @param path the path to be changed, null ignored
    485     * @return the updated path
    486     * @since 2.12.0
    487     */
    488    public String normalizeSeparators(final String path) {
    489        return replace(path, nameSeparatorOther, nameSeparator);
    490    }
    491
    492    /**
    493     * Tests whether this file system support driver letters.
    494     * <p>
    495     * Windows supports driver letters as do other operating systems. Whether these other OS's still support Java like
    496     * OS/2, is a different matter.
    497     * </p>
    498     *
    499     * @return whether this file system support driver letters.
    500     * @since 2.9.0
    501     * @see <a href="https://en.wikipedia.org/wiki/Drive_letter_assignment">Operating systems that use drive letter
    502     *      assignment</a>
    503     */
    504    public boolean supportsDriveLetter() {
    505        return supportsDriveLetter;
    506    }
    507
    508    /**
    509     * Converts a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} to a legal file
    510     * name. Illegal characters in the candidate name are replaced by the {@code replacement} character. If the file
    511     * name length exceeds {@link #getMaxFileNameLength()}, then the name is truncated to
    512     * {@link #getMaxFileNameLength()}.
    513     *
    514     * @param candidate
    515     *            a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}
    516     * @param replacement
    517     *            Illegal characters in the candidate name are replaced by this character
    518     * @return a String without illegal characters
    519     */
    520    public String toLegalFileName(final String candidate, final char replacement) {
    521        if (isIllegalFileNameChar(replacement)) {
    522            // %s does not work properly with NUL
    523            throw new IllegalArgumentException(String.format("The replacement character '%s' cannot be one of the %s illegal characters: %s",
    524                replacement == '\0' ? "\\0" : replacement, name(), Arrays.toString(illegalFileNameChars)));
    525        }
    526        final String truncated = candidate.length() > maxFileNameLength ? candidate.substring(0, maxFileNameLength) : candidate;
    527        final int[] array = truncated.chars().map(i -> isIllegalFileNameChar(i) ? replacement : i).toArray();
    528        return new String(array, 0, array.length);
    529    }
    530
    531    CharSequence trimExtension(final CharSequence cs) {
    532        final int index = indexOf(cs, '.', 0);
    533        return index < 0 ? cs : cs.subSequence(0, index);
    534    }
    535}