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(false, false, Integer.MAX_VALUE, Integer.MAX_VALUE, new char[] { 0 }, new String[] {}),
040
041    /**
042     * Linux file system.
043     */
044    LINUX(true, true, 255, 4096, new char[] {
045            // KEEP THIS ARRAY SORTED!
046            // @formatter:off
047            // ASCII NUL
048            0,
049             '/'
050            // @formatter:on
051    }, new String[] {}),
052
053    /**
054     * MacOS file system.
055     */
056    MAC_OSX(true, true, 255, 1024, new char[] {
057            // KEEP THIS ARRAY SORTED!
058            // @formatter:off
059            // ASCII NUL
060            0,
061            '/',
062             ':'
063            // @formatter:on
064    }, new String[] {}),
065
066    /**
067     * Windows file system.
068     */
069    WINDOWS(false, true, 255,
070            32000, new char[] {
071                    // KEEP THIS ARRAY SORTED!
072                    // @formatter:off
073                    // ASCII NUL
074                    0,
075                    // 1-31 may be allowed in file streams
076                    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,
077                    29, 30, 31,
078                    '"', '*', '/', ':', '<', '>', '?', '\\', '|'
079                    // @formatter:on
080            }, // KEEP THIS ARRAY SORTED!
081            new String[] { "AUX", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", "CON", "LPT1",
082                    "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "NUL", "PRN" });
083
084    /**
085     * <p>
086     * Is {@code true} if this is Linux.
087     * </p>
088     * <p>
089     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
090     * </p>
091     */
092    private static final boolean IS_OS_LINUX = getOsMatchesName("Linux");
093
094    /**
095     * <p>
096     * Is {@code true} if this is Mac.
097     * </p>
098     * <p>
099     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
100     * </p>
101     */
102    private static final boolean IS_OS_MAC = getOsMatchesName("Mac");
103
104    /**
105     * The prefix String for all Windows OS.
106     */
107    private static final String OS_NAME_WINDOWS_PREFIX = "Windows";
108
109    /**
110     * <p>
111     * Is {@code true} if this is Windows.
112     * </p>
113     * <p>
114     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
115     * </p>
116     */
117    private static final boolean IS_OS_WINDOWS = getOsMatchesName(OS_NAME_WINDOWS_PREFIX);
118
119    /**
120     * Gets the current file system.
121     *
122     * @return the current file system
123     */
124    public static FileSystem getCurrent() {
125        if (IS_OS_LINUX) {
126            return LINUX;
127        }
128        if (IS_OS_MAC) {
129            return FileSystem.MAC_OSX;
130        }
131        if (IS_OS_WINDOWS) {
132            return FileSystem.WINDOWS;
133        }
134        return GENERIC;
135    }
136
137    /**
138     * Decides if the operating system matches.
139     *
140     * @param osNamePrefix
141     *            the prefix for the os name
142     * @return true if matches, or false if not or can't determine
143     */
144    private static boolean getOsMatchesName(final String osNamePrefix) {
145        return isOsNameMatch(getSystemProperty("os.name"), osNamePrefix);
146    }
147
148    /**
149     * <p>
150     * Gets a System property, defaulting to {@code null} if the property cannot be read.
151     * </p>
152     * <p>
153     * If a {@code SecurityException} is caught, the return value is {@code null} and a message is written to
154     * {@code System.err}.
155     * </p>
156     *
157     * @param property
158     *            the system property name
159     * @return the system property value or {@code null} if a security problem occurs
160     */
161    private static String getSystemProperty(final String property) {
162        try {
163            return System.getProperty(property);
164        } catch (final SecurityException ex) {
165            // we are not allowed to look at this property
166            System.err.println("Caught a SecurityException reading the system property '" + property
167                    + "'; the SystemUtils property value will default to null.");
168            return null;
169        }
170    }
171
172    /**
173     * Decides if the operating system matches.
174     * <p>
175     * This method is package private instead of private to support unit test invocation.
176     * </p>
177     *
178     * @param osName
179     *            the actual OS name
180     * @param osNamePrefix
181     *            the prefix for the expected OS name
182     * @return true if matches, or false if not or can't determine
183     */
184    private static boolean isOsNameMatch(final String osName, final String osNamePrefix) {
185        if (osName == null) {
186            return false;
187        }
188        return osName.toUpperCase(Locale.ROOT).startsWith(osNamePrefix.toUpperCase(Locale.ROOT));
189    }
190
191    private final boolean casePreserving;
192    private final boolean caseSensitive;
193    private final char[] illegalFileNameChars;
194    private final int maxFileNameLength;
195    private final int maxPathLength;
196    private final String[] reservedFileNames;
197
198    /**
199     * Constructs a new instance.
200     *
201     * @param caseSensitive
202     *            Whether this file system is case sensitive.
203     * @param casePreserving
204     *            Whether this file system is case preserving.
205     * @param maxFileLength
206     *            The maximum length for file names. The file name does not include folders.
207     * @param maxPathLength
208     *            The maximum length of the path to a file. This can include folders.
209     * @param illegalFileNameChars
210     *            Illegal characters for this file system.
211     * @param reservedFileNames
212     *            The reserved file names.
213     */
214    FileSystem(final boolean caseSensitive, final boolean casePreserving, final int maxFileLength,
215               final int maxPathLength, final char[] illegalFileNameChars, final String[] reservedFileNames) {
216        this.maxFileNameLength = maxFileLength;
217        this.maxPathLength = maxPathLength;
218        this.illegalFileNameChars = Objects.requireNonNull(illegalFileNameChars, "illegalFileNameChars");
219        this.reservedFileNames = Objects.requireNonNull(reservedFileNames, "reservedFileNames");
220        this.caseSensitive = caseSensitive;
221        this.casePreserving = casePreserving;
222    }
223
224    /**
225     * Gets a cloned copy of the illegal characters for this file system.
226     *
227     * @return the illegal characters for this file system.
228     */
229    public char[] getIllegalFileNameChars() {
230        return this.illegalFileNameChars.clone();
231    }
232
233    /**
234     * Gets the maximum length for file names. The file name does not include folders.
235     *
236     * @return the maximum length for file names.
237     */
238    public int getMaxFileNameLength() {
239        return maxFileNameLength;
240    }
241
242    /**
243     * Gets the maximum length of the path to a file. This can include folders.
244     *
245     * @return the maximum length of the path to a file.
246     */
247    public int getMaxPathLength() {
248        return maxPathLength;
249    }
250
251    /**
252     * Gets a cloned copy of the reserved file names.
253     *
254     * @return the reserved file names.
255     */
256    public String[] getReservedFileNames() {
257        return reservedFileNames.clone();
258    }
259
260    /**
261     * Whether this file system preserves case.
262     *
263     * @return Whether this file system preserves case.
264     */
265    public boolean isCasePreserving() {
266        return casePreserving;
267    }
268
269    /**
270     * Whether this file system is case-sensitive.
271     *
272     * @return Whether this file system is case-sensitive.
273     */
274    public boolean isCaseSensitive() {
275        return caseSensitive;
276    }
277
278    /**
279     * Returns {@code true} if the given character is illegal in a file name, {@code false} otherwise.
280     *
281     * @param c
282     *            the character to test
283     * @return {@code true} if the given character is illegal in a file name, {@code false} otherwise.
284     */
285    private boolean isIllegalFileNameChar(final char c) {
286        return Arrays.binarySearch(illegalFileNameChars, c) >= 0;
287    }
288
289    /**
290     * Checks if a candidate file name (without a path) such as {@code "filename.ext"} or {@code "filename"} is a
291     * potentially legal file name. If the file name length exceeds {@link #getMaxFileNameLength()}, or if it contains
292     * an illegal character then the check fails.
293     *
294     * @param candidate
295     *            a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}
296     * @return {@code true} if the candidate name is legal
297     */
298    public boolean isLegalFileName(final CharSequence candidate) {
299        if (candidate == null || candidate.length() == 0 || candidate.length() > maxFileNameLength) {
300            return false;
301        }
302        if (isReservedFileName(candidate)) {
303            return false;
304        }
305        for (int i = 0; i < candidate.length(); i++) {
306            if (isIllegalFileNameChar(candidate.charAt(i))) {
307                return false;
308            }
309        }
310        return true;
311    }
312
313    /**
314     * Returns whether the given string is a reserved file name.
315     *
316     * @param candidate
317     *            the string to test
318     * @return {@code true} if the given string is a reserved file name.
319     */
320    public boolean isReservedFileName(final CharSequence candidate) {
321        return Arrays.binarySearch(reservedFileNames, candidate) >= 0;
322    }
323
324    /**
325     * Converts a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} to a legal file
326     * name. Illegal characters in the candidate name are replaced by the {@code replacement} character. If the file
327     * name length exceeds {@link #getMaxFileNameLength()}, then the name is truncated to
328     * {@link #getMaxFileNameLength()}.
329     *
330     * @param candidate
331     *            a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}
332     * @param replacement
333     *            Illegal characters in the candidate name are replaced by this character
334     * @return a String without illegal characters
335     */
336    public String toLegalFileName(final String candidate, final char replacement) {
337        if (isIllegalFileNameChar(replacement)) {
338            throw new IllegalArgumentException(
339                    String.format("The replacement character '%s' cannot be one of the %s illegal characters: %s",
340                            // %s does not work properly with NUL
341                            replacement == '\0' ? "\\0" : replacement, name(), Arrays.toString(illegalFileNameChars)));
342        }
343        final String truncated = candidate.length() > maxFileNameLength ? candidate.substring(0, maxFileNameLength)
344                : candidate;
345        boolean changed = false;
346        final char[] charArray = truncated.toCharArray();
347        for (int i = 0; i < charArray.length; i++) {
348            if (isIllegalFileNameChar(charArray[i])) {
349                charArray[i] = replacement;
350                changed = true;
351            }
352        }
353        return changed ? String.valueOf(charArray) : truncated;
354    }
355}