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 *      https://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 static java.nio.charset.StandardCharsets.UTF_16;
021
022import java.nio.ByteBuffer;
023import java.nio.CharBuffer;
024import java.nio.charset.CharacterCodingException;
025import java.nio.charset.Charset;
026import java.nio.charset.CharsetEncoder;
027import java.nio.charset.CoderResult;
028import java.nio.charset.CodingErrorAction;
029import java.text.BreakIterator;
030import java.util.Arrays;
031import java.util.Locale;
032import java.util.Objects;
033
034/**
035 * Abstracts an OS' file system details, currently supporting the single use case of converting a file name String to a
036 * legal file name with {@link #toLegalFileName(String, char)}.
037 * <p>
038 * The starting point of any operation is {@link #getCurrent()} which gets you the enum for the file system that matches
039 * the OS hosting the running JVM.
040 * </p>
041 *
042 * @since 2.7
043 */
044public enum FileSystem {
045
046    /**
047     * Generic file system.
048     */
049    GENERIC(4096, false, false, 1020, 1024 * 1024, new int[] {
050            // @formatter:off
051            // ASCII NUL
052            0
053            // @formatter:on
054    }, new String[] {}, false, false, '/', NameLengthStrategy.BYTES),
055
056    /**
057     * Linux file system.
058     */
059    LINUX(8192, true, true, 255, 4096, new int[] {
060            // KEEP THIS ARRAY SORTED!
061            // @formatter:off
062            // ASCII NUL
063            0,
064             '/'
065            // @formatter:on
066    }, new String[] {}, false, false, '/', NameLengthStrategy.BYTES),
067
068    /**
069     * MacOS file system.
070     */
071    MAC_OSX(4096, true, true, 255, 1024, new int[] {
072            // KEEP THIS ARRAY SORTED!
073            // @formatter:off
074            // ASCII NUL
075            0,
076            '/',
077             ':'
078            // @formatter:on
079    }, new String[] {}, false, false, '/', NameLengthStrategy.BYTES),
080
081    /**
082     * Windows file system.
083     * <p>
084     * The reserved characters are defined in the
085     * <a href="https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file">Naming Conventions
086     * (microsoft.com)</a>.
087     * </p>
088     *
089     * @see <a href="https://docs.microsoft.com/en-us/windows/win32/fileio/naming-a-file">Naming Conventions
090     *      (microsoft.com)</a>
091     * @see <a href="https://docs.microsoft.com/en-us/windows/win32/api/fileapi/nf-fileapi-createfilea#consoles">
092     *      CreateFileA function - Consoles (microsoft.com)</a>
093     */
094    // @formatter:off
095    WINDOWS(4096, false, true,
096            255, 32767, // KEEP THIS ARRAY SORTED!
097            new int[] {
098                    // KEEP THIS ARRAY SORTED!
099                    // ASCII NUL
100                    0,
101                    // 1-31 may be allowed in file streams
102                    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,
103                    29, 30, 31,
104                    '"', '*', '/', ':', '<', '>', '?', '\\', '|'
105            }, new String[] {
106                    "AUX",
107                    "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
108                    "COM\u00b2", "COM\u00b3", "COM\u00b9", // Superscript 2 3 1 in that order
109                    "CON", "CONIN$", "CONOUT$",
110                    "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
111                    "LPT\u00b2", "LPT\u00b3", "LPT\u00b9", // Superscript 2 3 1 in that order
112                    "NUL", "PRN"
113            }, true, true, '\\', NameLengthStrategy.UTF16_CODE_UNITS);
114    // @formatter:on
115
116    /**
117     * Strategy for measuring and truncating file or path names in different units.
118     * Implementations measure length and can truncate to a specified limit.
119     */
120    enum NameLengthStrategy {
121        /** Length measured as encoded bytes. */
122        BYTES {
123            @Override
124            int getLength(final CharSequence value, final Charset charset) {
125                final CharsetEncoder enc = charset.newEncoder()
126                        .onMalformedInput(CodingErrorAction.REPORT)
127                        .onUnmappableCharacter(CodingErrorAction.REPORT);
128                try {
129                    return enc.encode(CharBuffer.wrap(value)).remaining();
130                } catch (final CharacterCodingException e) {
131                    // Unencodable, does not fit any byte limit.
132                    return Integer.MAX_VALUE;
133                }
134            }
135
136            @Override
137            CharSequence truncate(final CharSequence value, final int limit, final Charset charset) {
138                final CharsetEncoder encoder = charset.newEncoder()
139                        .onMalformedInput(CodingErrorAction.REPORT)
140                        .onUnmappableCharacter(CodingErrorAction.REPORT);
141                if (!encoder.canEncode(value)) {
142                    throw new IllegalArgumentException("The value " + value + " cannot be encoded using " + charset.name());
143                }
144                // Fast path: if even the worst-case expansion fits, we're done.
145                if (value.length() <= Math.floor(limit / encoder.maxBytesPerChar())) {
146                    return value;
147                }
148                // Slow path: encode into a fixed-size byte buffer.
149                // 1. Compute length of extension in bytes (if any).
150                final CharSequence[] parts = splitExtension(value);
151                final int extensionLength = getLength(parts[1], charset);
152                if (extensionLength > 0 && extensionLength >= limit) {
153                    // Extension itself does not fit
154                    throw new IllegalArgumentException("The extension of " + value + " is too long to fit within " + limit + " bytes");
155                }
156                // 2. Compute the character part that fits within the remaining byte budget.
157                final ByteBuffer byteBuffer = ByteBuffer.allocate(limit - extensionLength);
158                final CharBuffer charBuffer = CharBuffer.wrap(parts[0]);
159                // Encode until the first character that would exceed the byte budget.
160                final CoderResult cr = encoder.encode(charBuffer, byteBuffer, true);
161                if (cr.isUnderflow()) {
162                    // Entire candidate fit within maxFileNameLength bytes.
163                    return value;
164                }
165                final CharSequence truncated = safeTruncate(value, charBuffer.position());
166                return extensionLength == 0 ? truncated : truncated.toString() + parts[1];
167            }
168        },
169
170        /** Length measured as UTF-16 code units (i.e., {@code CharSequence.length()}). */
171        UTF16_CODE_UNITS {
172            @Override
173            int getLength(final CharSequence value, final Charset charset) {
174                return value.length();
175            }
176
177            @Override
178            CharSequence truncate(final CharSequence value, final int limit, final Charset charset) {
179                if (!UTF_16.newEncoder().canEncode(value)) {
180                    throw new IllegalArgumentException("The value " + value + " can not be encoded using " + UTF_16.name());
181                }
182                // Fast path: no truncation needed.
183                if (value.length() <= limit) {
184                    return value;
185                }
186                // Slow path: truncate to limit.
187                // 1. Compute length of extension in chars (if any).
188                final CharSequence[] parts = splitExtension(value);
189                final int extensionLength = parts[1].length();
190                if (extensionLength > 0 && extensionLength >= limit) {
191                    // Extension itself does not fit
192                    throw new IllegalArgumentException("The extension of " + value + " is too long to fit within " + limit + " characters");
193                }
194                // 2. Truncate the non-extension part and append the extension (if any).
195                final CharSequence truncated = safeTruncate(value, limit - extensionLength);
196                return extensionLength == 0 ? truncated : truncated.toString() + parts[1];
197            }
198        };
199
200        /**
201         * Gets the measured length in this strategy’s unit.
202         *
203         * @param value The value to measure, not null.
204         * @param charset The charset to use when measuring in bytes.
205         * @return The length in this strategy’s unit.
206         */
207        abstract int getLength(CharSequence value, Charset charset);
208
209        /**
210         * Tests if the measured length is less or equal the {@code limit}.
211         *
212         * @param value The value to measure, not null.
213         * @param limit The limit to compare to.
214         * @param charset The charset to use when measuring in bytes.
215         * @return {@code true} if the measured length is less or equal the {@code limit}, {@code false} otherwise.
216         */
217        final boolean isWithinLimit(final CharSequence value, final int limit, final Charset charset) {
218            return getLength(value, charset) <= limit;
219        }
220
221        /**
222         * Truncates to {@code limit} in this strategy’s unit (no-op if already within limit).
223         *
224         * @param value The value to truncate, not null.
225         * @param limit The limit to truncate to.
226         * @param charset The charset to use when measuring in bytes.
227         * @return The truncated value, not null.
228         */
229        abstract CharSequence truncate(CharSequence value, int limit, Charset charset);
230    }
231
232    /**
233     * Is {@code true} if this is Linux.
234     * <p>
235     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
236     * </p>
237     */
238    private static final boolean IS_OS_LINUX = getOsMatchesName("Linux");
239
240    /**
241     * Is {@code true} if this is Mac.
242     * <p>
243     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
244     * </p>
245     */
246    private static final boolean IS_OS_MAC = getOsMatchesName("Mac");
247
248    /**
249     * The prefix String for all Windows OS.
250     */
251    private static final String OS_NAME_WINDOWS_PREFIX = "Windows";
252
253    /**
254     * Is {@code true} if this is Windows.
255     * <p>
256     * The field will return {@code false} if {@code OS_NAME} is {@code null}.
257     * </p>
258     */
259    private static final boolean IS_OS_WINDOWS = getOsMatchesName(OS_NAME_WINDOWS_PREFIX);
260
261    /**
262     * The current FileSystem.
263     */
264    private static final FileSystem CURRENT = current();
265
266    /**
267     * Gets the current file system.
268     *
269     * @return the current file system
270     */
271    private static FileSystem current() {
272        if (IS_OS_LINUX) {
273            return LINUX;
274        }
275        if (IS_OS_MAC) {
276            return MAC_OSX;
277        }
278        if (IS_OS_WINDOWS) {
279            return WINDOWS;
280        }
281        return GENERIC;
282    }
283
284    /**
285     * Gets the current file system.
286     *
287     * @return the current file system
288     */
289    public static FileSystem getCurrent() {
290        return CURRENT;
291    }
292
293    /**
294     * Decides if the operating system matches.
295     *
296     * @param osNamePrefix
297     *            the prefix for the os name
298     * @return true if matches, or false if not or can't determine
299     */
300    private static boolean getOsMatchesName(final String osNamePrefix) {
301        return isOsNameMatch(getSystemProperty("os.name"), osNamePrefix);
302    }
303
304    /**
305     * Gets a System property, defaulting to {@code null} if the property cannot be read.
306     * <p>
307     * If a {@link SecurityException} is caught, the return value is {@code null} and a message is written to
308     * {@code System.err}.
309     * </p>
310     *
311     * @param property
312     *            the system property name
313     * @return the system property value or {@code null} if a security problem occurs
314     */
315    private static String getSystemProperty(final String property) {
316        try {
317            return System.getProperty(property);
318        } catch (final SecurityException ex) {
319            // we are not allowed to look at this property
320            System.err.println("Caught a SecurityException reading the system property '" + property
321                    + "'; the SystemUtils property value will default to null.");
322            return null;
323        }
324    }
325
326    /*
327     * Finds the index of the first dot in a CharSequence.
328     */
329    private static int indexOfFirstDot(final CharSequence cs) {
330        if (cs instanceof String) {
331            return ((String) cs).indexOf('.');
332        }
333        for (int i = 0; i < cs.length(); i++) {
334            if (cs.charAt(i) == '.') {
335                return i;
336            }
337        }
338        return -1;
339    }
340
341    /**
342     * Decides if the operating system matches.
343     * <p>
344     * This method is package private instead of private to support unit test invocation.
345     * </p>
346     *
347     * @param osName
348     *            the actual OS name
349     * @param osNamePrefix
350     *            the prefix for the expected OS name
351     * @return true if matches, or false if not or can't determine
352     */
353    private static boolean isOsNameMatch(final String osName, final String osNamePrefix) {
354        if (osName == null) {
355            return false;
356        }
357        return osName.toUpperCase(Locale.ROOT).startsWith(osNamePrefix.toUpperCase(Locale.ROOT));
358    }
359
360    /**
361     * Null-safe replace.
362     *
363     * @param path the path to be changed, null ignored.
364     * @param oldChar the old character.
365     * @param newChar the new character.
366     * @return the new path.
367     */
368    private static String replace(final String path, final char oldChar, final char newChar) {
369        return path == null ? null : path.replace(oldChar, newChar);
370    }
371
372    /**
373     * Truncates a string respecting grapheme cluster boundaries.
374     *
375     * @param value The value to truncate.
376     * @param limit The maximum length.
377     * @return The truncated value.
378     * @throws IllegalArgumentException If the first grapheme cluster is longer than the limit.
379     */
380    private static CharSequence safeTruncate(final CharSequence value, final int limit) {
381        if (value.length() <= limit) {
382            return value;
383        }
384        final BreakIterator boundary = BreakIterator.getCharacterInstance(Locale.ROOT);
385        final String text = value.toString();
386        boundary.setText(text);
387        final int end = boundary.preceding(limit + 1);
388        assert end != BreakIterator.DONE;
389        if (end == 0) {
390            final String limitMessage = limit <= 1 ? "1 character" : limit + " characters";
391            throw new IllegalArgumentException("The value " + value + " can not be truncated to " + limitMessage
392                    + " without breaking the first codepoint or grapheme cluster");
393        }
394        return text.substring(0, end);
395    }
396    static CharSequence[] splitExtension(final CharSequence value) {
397        final int index = indexOfFirstDot(value);
398        // An initial dot is not an extension
399        return index < 1
400                ? new CharSequence[] {value, ""}
401                : new CharSequence[] {value.subSequence(0, index), value.subSequence(index, value.length())};
402    }
403    static CharSequence trimExtension(final CharSequence cs) {
404        final int index = indexOfFirstDot(cs);
405        // An initial dot is not an extension
406        return index < 1 ? cs : cs.subSequence(0, index);
407    }
408    private final int blockSize;
409    private final boolean casePreserving;
410    private final boolean caseSensitive;
411    private final int[] illegalFileNameChars;
412    private final int maxFileNameLength;
413    private final int maxPathLength;
414    private final String[] reservedFileNames;
415    private final boolean reservedFileNamesExtensions;
416
417    private final boolean supportsDriveLetter;
418
419    private final char nameSeparator;
420
421    private final char nameSeparatorOther;
422
423    private final NameLengthStrategy nameLengthStrategy;
424
425    /**
426     * Constructs a new instance.
427     *
428     * @param blockSize file allocation block size in bytes.
429     * @param caseSensitive Whether this file system is case-sensitive.
430     * @param casePreserving Whether this file system is case-preserving.
431     * @param maxFileLength The maximum length for file names. The file name does not include folders.
432     * @param maxPathLength The maximum length of the path to a file. This can include folders.
433     * @param illegalFileNameChars Illegal characters for this file system.
434     * @param reservedFileNames The reserved file names.
435     * @param reservedFileNamesExtensions The reserved file name extensions.
436     * @param supportsDriveLetter Whether this file system support driver letters.
437     * @param nameSeparator The name separator, '\\' on Windows, '/' on Linux.
438     * @param nameLengthStrategy The strategy for measuring and truncating file and path names.
439     */
440    FileSystem(final int blockSize, final boolean caseSensitive, final boolean casePreserving,
441        final int maxFileLength, final int maxPathLength, final int[] illegalFileNameChars,
442        final String[] reservedFileNames, final boolean reservedFileNamesExtensions, final boolean supportsDriveLetter,
443        final char nameSeparator, final NameLengthStrategy nameLengthStrategy) {
444        this.blockSize = blockSize;
445        this.maxFileNameLength = maxFileLength;
446        this.maxPathLength = maxPathLength;
447        this.illegalFileNameChars = Objects.requireNonNull(illegalFileNameChars, "illegalFileNameChars");
448        this.reservedFileNames = Objects.requireNonNull(reservedFileNames, "reservedFileNames");
449        //Arrays.sort(this.reservedFileNames);
450        this.reservedFileNamesExtensions = reservedFileNamesExtensions;
451        this.caseSensitive = caseSensitive;
452        this.casePreserving = casePreserving;
453        this.supportsDriveLetter = supportsDriveLetter;
454        this.nameSeparator = nameSeparator;
455        this.nameSeparatorOther = FilenameUtils.flipSeparator(nameSeparator);
456        this.nameLengthStrategy = nameLengthStrategy;
457    }
458
459    /**
460     * Gets the file allocation block size in bytes.
461     *
462     * @return the file allocation block size in bytes.
463     * @since 2.12.0
464     */
465    public int getBlockSize() {
466        return blockSize;
467    }
468
469    /**
470     * Gets a cloned copy of the illegal characters for this file system.
471     *
472     * @return the illegal characters for this file system.
473     */
474    public char[] getIllegalFileNameChars() {
475        final char[] chars = new char[illegalFileNameChars.length];
476        for (int i = 0; i < illegalFileNameChars.length; i++) {
477            chars[i] = (char) illegalFileNameChars[i];
478        }
479        return chars;
480    }
481
482    /**
483     * Gets a cloned copy of the illegal code points for this file system.
484     *
485     * @return the illegal code points for this file system.
486     * @since 2.12.0
487     */
488    public int[] getIllegalFileNameCodePoints() {
489        return this.illegalFileNameChars.clone();
490    }
491
492    /**
493     * Gets the maximum length for file names (excluding any folder path).
494     *
495     * <p>
496     * This limit applies only to the file name itself, excluding any parent directories.
497     * </p>
498     *
499     * <p>
500     * The value is expressed in Java {@code char} units (UTF-16 code units).
501     * </p>
502     *
503     * <p>
504     * <strong>Note:</strong> Because many file systems enforce limits in <em>bytes</em> using a specific encoding rather than in UTF-16 code units, a name that
505     * fits this limit may still be rejected by the underlying file system.
506     * </p>
507     *
508     * <p>
509     * Use {@link #isLegalFileName} to check whether a given name is valid for the current file system and charset.
510     * </p>
511     *
512     * <p>
513     * However, any file name longer than this limit is guaranteed to be invalid on the current file system.
514     * </p>
515     *
516     * @return the maximum file name length in characters.
517     */
518    public int getMaxFileNameLength() {
519        return maxFileNameLength;
520    }
521
522    /**
523     * Gets the maximum length for file paths (may include folders).
524     *
525     * <p>
526     * This value is inclusive of all path components and separators. For a limit of each path component see {@link #getMaxFileNameLength()}.
527     * </p>
528     *
529     * <p>
530     * The value is expressed in Java {@code char} units (UTF-16 code units) and represents the longest path that can be safely passed to Java
531     * {@link java.io.File} and {@link java.nio.file.Path} APIs.
532     * </p>
533     *
534     * <p>
535     * <strong>Note:</strong> many operating systems and file systems enforce path length limits in <em>bytes</em> using a specific encoding, rather than in
536     * UTF-16 code units. As a result, a path that fits within this limit may still be rejected by the underlying platform.
537     * </p>
538     *
539     * <p>
540     * Conversely, any path longer than this limit is guaranteed to fail with at least some operating system API calls.
541     * </p>
542     *
543     * @return the maximum file path length in characters.
544     */
545    public int getMaxPathLength() {
546        return maxPathLength;
547    }
548
549    NameLengthStrategy getNameLengthStrategy() {
550        return nameLengthStrategy;
551    }
552
553    /**
554     * Gets the name separator, '\\' on Windows, '/' on Linux.
555     *
556     * @return '\\' on Windows, '/' on Linux.
557     * @since 2.12.0
558     */
559    public char getNameSeparator() {
560        return nameSeparator;
561    }
562
563    /**
564     * Gets a cloned copy of the reserved file names.
565     *
566     * @return the reserved file names.
567     */
568    public String[] getReservedFileNames() {
569        return reservedFileNames.clone();
570    }
571
572    /**
573     * Tests whether this file system preserves case.
574     *
575     * @return Whether this file system preserves case.
576     */
577    public boolean isCasePreserving() {
578        return casePreserving;
579    }
580
581    /**
582     * Tests whether this file system is case-sensitive.
583     *
584     * @return Whether this file system is case-sensitive.
585     */
586    public boolean isCaseSensitive() {
587        return caseSensitive;
588    }
589
590    /**
591     * Tests if the given character is illegal in a file name, {@code false} otherwise.
592     *
593     * @param c
594     *            the character to test.
595     * @return {@code true} if the given character is illegal in a file name, {@code false} otherwise.
596     */
597    private boolean isIllegalFileNameChar(final int c) {
598        return Arrays.binarySearch(illegalFileNameChars, c) >= 0;
599    }
600
601    /**
602     * Tests if a candidate file name (without a path) is a legal file name.
603     *
604     * <p>Takes a file name like {@code "filename.ext"} or {@code "filename"} and checks:</p>
605     * <ul>
606     * <li>if the file name length is legal</li>
607     * <li>if the file name is not a reserved file name</li>
608     * <li>if the file name does not contain illegal characters</li>
609     * </ul>
610     *
611     * @param candidate
612     *            A candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}.
613     * @return {@code true} if the candidate name is legal.
614     */
615    public boolean isLegalFileName(final CharSequence candidate) {
616        return isLegalFileName(candidate, Charset.defaultCharset());
617    }
618
619    /**
620     * Tests if a candidate file name (without a path) is a legal file name.
621     *
622     * <p>Takes a file name like {@code "filename.ext"} or {@code "filename"} and checks:</p>
623     * <ul>
624     * <li>if the file name length is legal</li>
625     * <li>if the file name is not a reserved file name</li>
626     * <li>if the file name does not contain illegal characters</li>
627     * </ul>
628     *
629     * @param candidate
630     *            A candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}.
631     * @param charset
632     *            The charset to use when the file name length is measured in bytes.
633     * @return {@code true} if the candidate name is legal.
634     * @since 2.21.0
635     */
636    public boolean isLegalFileName(final CharSequence candidate, final Charset charset) {
637        return candidate != null
638                && candidate.length() != 0
639                && nameLengthStrategy.isWithinLimit(candidate, getMaxFileNameLength(), charset)
640                && !isReservedFileName(candidate)
641                && candidate.chars().noneMatch(this::isIllegalFileNameChar);
642    }
643
644    /**
645     * Tests whether the given string is a reserved file name.
646     *
647     * @param candidate
648     *            the string to test.
649     * @return {@code true} if the given string is a reserved file name.
650     */
651    public boolean isReservedFileName(final CharSequence candidate) {
652        final CharSequence test = reservedFileNamesExtensions ? trimExtension(candidate) : candidate;
653        return Arrays.binarySearch(reservedFileNames, test) >= 0;
654    }
655
656    /**
657     * Converts all separators to the Windows separator of backslash.
658     *
659     * @param path the path to be changed, null ignored.
660     * @return the updated path.
661     * @since 2.12.0
662     */
663    public String normalizeSeparators(final String path) {
664        return replace(path, nameSeparatorOther, nameSeparator);
665    }
666
667    /**
668     * Tests whether this file system support driver letters.
669     * <p>
670     * Windows supports driver letters as do other operating systems. Whether these other OS's still support Java like
671     * OS/2, is a different matter.
672     * </p>
673     *
674     * @return whether this file system support driver letters.
675     * @since 2.9.0
676     * @see <a href="https://en.wikipedia.org/wiki/Drive_letter_assignment">Operating systems that use drive letter
677     *      assignment</a>
678     */
679    public boolean supportsDriveLetter() {
680        return supportsDriveLetter;
681    }
682
683    /**
684     * Converts a candidate file name (without a path) to a legal file name.
685     *
686     * <p>Takes a file name like {@code "filename.ext"} or {@code "filename"} and:</p>
687     * <ul>
688     *     <li>replaces illegal characters by the given replacement character</li>
689     *     <li>truncates the name to {@link #getMaxFileNameLength()} if necessary</li>
690     * </ul>
691     *
692     * @param candidate
693     *            A candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}.
694     * @param replacement
695     *            Illegal characters in the candidate name are replaced by this character.
696     * @param charset
697     *            The charset to use when the file name length is measured in bytes.
698     * @return a String without illegal characters.
699     * @since 2.21.0
700     */
701    public String toLegalFileName(final CharSequence candidate, final char replacement, final Charset charset) {
702        Objects.requireNonNull(candidate, "candidate");
703        if (candidate.length() == 0) {
704            throw new IllegalArgumentException("The candidate file name is empty");
705        }
706        if (isIllegalFileNameChar(replacement)) {
707            // %s does not work properly with NUL
708            throw new IllegalArgumentException(String.format("The replacement character '%s' cannot be one of the %s illegal characters: %s",
709                replacement == '\0' ? "\\0" : replacement, name(), Arrays.toString(illegalFileNameChars)));
710        }
711        final CharSequence truncated = nameLengthStrategy.truncate(candidate, getMaxFileNameLength(), charset);
712        final int[] array = truncated.chars().map(i -> isIllegalFileNameChar(i) ? replacement : i).toArray();
713        return new String(array, 0, array.length);
714    }
715
716
717    /**
718     * Converts a candidate file name (without a path) to a legal file name.
719     *
720     * <p>Takes a file name like {@code "filename.ext"} or {@code "filename"} and:</p>
721     * <ul>
722     *     <li>replaces illegal characters by the given replacement character</li>
723     *     <li>truncates the name to {@link #getMaxFileNameLength()} if necessary</li>
724     * </ul>
725     *
726     * @param candidate
727     *            A candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"}.
728     * @param replacement
729     *            Illegal characters in the candidate name are replaced by this character.
730     * @return a String without illegal characters.
731     */
732    public String toLegalFileName(final String candidate, final char replacement) {
733        return toLegalFileName(candidate, replacement, Charset.defaultCharset());
734    }
735
736}