diff --git a/src/changes/changes.xml b/src/changes/changes.xml index a50fe9d3587..0b72c76f9ee 100644 --- a/src/changes/changes.xml +++ b/src/changes/changes.xml @@ -60,6 +60,7 @@ The type attribute can be add,update,fix,remove. Add org.apache.commons.io.output.ProxyOutputStream.writeRepeat(byte[], int, int, long). Add org.apache.commons.io.output.ProxyOutputStream.writeRepeat(byte[], long). Add org.apache.commons.io.output.ProxyOutputStream.writeRepeat(int, long). + Add length unit support in FileSystem limits. Add IOUtils.toByteArray(InputStream, int, int) for safer chunked reading with size validation. Bump org.apache.commons:commons-parent from 85 to 87 #774. diff --git a/src/main/java/org/apache/commons/io/FileSystem.java b/src/main/java/org/apache/commons/io/FileSystem.java index 0e0ddf05aff..3cccbe7f2d6 100644 --- a/src/main/java/org/apache/commons/io/FileSystem.java +++ b/src/main/java/org/apache/commons/io/FileSystem.java @@ -17,6 +17,16 @@ package org.apache.commons.io; +import static java.nio.charset.StandardCharsets.UTF_16; + +import java.nio.ByteBuffer; +import java.nio.CharBuffer; +import java.nio.charset.CharacterCodingException; +import java.nio.charset.Charset; +import java.nio.charset.CharsetEncoder; +import java.nio.charset.CoderResult; +import java.nio.charset.CodingErrorAction; +import java.text.BreakIterator; import java.util.Arrays; import java.util.Locale; import java.util.Objects; @@ -36,7 +46,12 @@ public enum FileSystem { /** * Generic file system. */ - GENERIC(4096, false, false, Integer.MAX_VALUE, Integer.MAX_VALUE, new int[] { 0 }, new String[] {}, false, false, '/'), + GENERIC(4096, false, false, 1020, 1024 * 1024, new int[] { + // @formatter:off + // ASCII NUL + 0 + // @formatter:on + }, new String[] {}, false, false, '/', NameLengthStrategy.BYTES), /** * Linux file system. @@ -48,7 +63,7 @@ public enum FileSystem { 0, '/' // @formatter:on - }, new String[] {}, false, false, '/'), + }, new String[] {}, false, false, '/', NameLengthStrategy.BYTES), /** * MacOS file system. @@ -61,7 +76,7 @@ public enum FileSystem { '/', ':' // @formatter:on - }, new String[] {}, false, false, '/'), + }, new String[] {}, false, false, '/', NameLengthStrategy.BYTES), /** * Windows file system. @@ -78,7 +93,7 @@ public enum FileSystem { */ // @formatter:off WINDOWS(4096, false, true, - 255, 32000, // KEEP THIS ARRAY SORTED! + 255, 32767, // KEEP THIS ARRAY SORTED! new int[] { // KEEP THIS ARRAY SORTED! // ASCII NUL @@ -95,7 +110,7 @@ public enum FileSystem { "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9", "LPT\u00b2", "LPT\u00b3", "LPT\u00b9", // Superscript 2 3 1 in that order "NUL", "PRN" - }, true, true, '\\'); + }, true, true, '\\', NameLengthStrategy.UTF16_CODE_UNITS); // @formatter:on /** @@ -200,74 +215,16 @@ private static String getSystemProperty(final String property) { } } - /** - * Copied from Apache Commons Lang CharSequenceUtils. - * - * Returns the index within {@code cs} of the first occurrence of the - * specified character, starting the search at the specified index. - *

- * If a character with value {@code searchChar} occurs in the - * character sequence represented by the {@code cs} - * object at an index no smaller than {@code start}, then - * the index of the first such occurrence is returned. For values - * of {@code searchChar} in the range from 0 to 0xFFFF (inclusive), - * this is the smallest value k such that: - *

- *
-     * (this.charAt(k) == searchChar) && (k >= start)
-     * 
- * is true. For other values of {@code searchChar}, it is the - * smallest value k such that: - *
-     * (this.codePointAt(k) == searchChar) && (k >= start)
-     * 
- *

- * is true. In either case, if no such character occurs in {@code cs} - * at or after position {@code start}, then - * {@code -1} is returned. - *

- *

- * There is no restriction on the value of {@code start}. If it - * is negative, it has the same effect as if it were zero: the entire - * {@link CharSequence} may be searched. If it is greater than - * the length of {@code cs}, it has the same effect as if it were - * equal to the length of {@code cs}: {@code -1} is returned. - *

- *

All indices are specified in {@code char} values - * (Unicode code units). - *

- * - * @param cs the {@link CharSequence} to be processed, not null - * @param searchChar the char to be searched for - * @param start the start index, negative starts at the string start - * @return the index where the search char was found, -1 if not found - * @since 3.6 updated to behave more like {@link String} + /* + * Finds the index of the first dot in a CharSequence. */ - private static int indexOf(final CharSequence cs, final int searchChar, int start) { + private static int indexOfFirstDot(final CharSequence cs) { if (cs instanceof String) { - return ((String) cs).indexOf(searchChar, start); - } - final int sz = cs.length(); - if (start < 0) { - start = 0; - } - if (searchChar < Character.MIN_SUPPLEMENTARY_CODE_POINT) { - for (int i = start; i < sz; i++) { - if (cs.charAt(i) == searchChar) { - return i; - } - } - return -1; + return ((String) cs).indexOf('.'); } - //supplementary characters (LANG1300) - if (searchChar <= Character.MAX_CODE_POINT) { - final char[] chars = Character.toChars(searchChar); - for (int i = start; i < sz - 1; i++) { - final char high = cs.charAt(i); - final char low = cs.charAt(i + 1); - if (high == chars[0] && low == chars[1]) { - return i; - } + for (int i = 0; i < cs.length(); i++) { + if (cs.charAt(i) == '.') { + return i; } } return -1; @@ -315,6 +272,7 @@ private static String replace(final String path, final char oldChar, final char private final boolean supportsDriveLetter; private final char nameSeparator; private final char nameSeparatorOther; + private final NameLengthStrategy nameLengthStrategy; /** * Constructs a new instance. @@ -329,10 +287,12 @@ private static String replace(final String path, final char oldChar, final char * @param reservedFileNamesExtensions TODO * @param supportsDriveLetter Whether this file system support driver letters. * @param nameSeparator The name separator, '\\' on Windows, '/' on Linux. + * @param nameLengthStrategy The strategy for measuring and truncating file and path names. */ FileSystem(final int blockSize, final boolean caseSensitive, final boolean casePreserving, final int maxFileLength, final int maxPathLength, final int[] illegalFileNameChars, - final String[] reservedFileNames, final boolean reservedFileNamesExtensions, final boolean supportsDriveLetter, final char nameSeparator) { + final String[] reservedFileNames, final boolean reservedFileNamesExtensions, final boolean supportsDriveLetter, + final char nameSeparator, final NameLengthStrategy nameLengthStrategy) { this.blockSize = blockSize; this.maxFileNameLength = maxFileLength; this.maxPathLength = maxPathLength; @@ -345,6 +305,7 @@ private static String replace(final String path, final char oldChar, final char this.supportsDriveLetter = supportsDriveLetter; this.nameSeparator = nameSeparator; this.nameSeparatorOther = FilenameUtils.flipSeparator(nameSeparator); + this.nameLengthStrategy = nameLengthStrategy; } /** @@ -380,18 +341,49 @@ public int[] getIllegalFileNameCodePoints() { } /** - * Gets the maximum length for file names. The file name does not include folders. + * Gets the maximum length for file names (excluding any folder path). + * + *

This limit applies only to the file name itself, excluding any parent + * directories.

+ * + *

The value is expressed in Java {@code char} units (UTF-16 code units).

+ * + *

Note: Because many file systems enforce limits in + * bytes using a specific encoding rather than in UTF-16 code + * units, a name that fits this limit may still be rejected by the + * underlying file system.

+ * + *

Use {@link #isLegalFileName} to check whether a given name is valid + * for the current file system and charset.

+ * + *

However, any file name longer than this limit is guaranteed to be + * invalid on the current file system.

* - * @return the maximum length for file names. + * @return the maximum file name length in characters. */ public int getMaxFileNameLength() { return maxFileNameLength; } /** - * Gets the maximum length of the path to a file. This can include folders. + * Gets the maximum length for file paths (may include folders). * - * @return the maximum length of the path to a file. + *

This value is inclusive of all path components and separators. + * For a limit of each path component see {@link #getMaxFileNameLength()}.

+ * + *

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 + * {@link java.io.File} and {@link java.nio.file.Path} APIs.

+ * + *

Note: many operating systems and file systems enforce + * path length limits in bytes using a specific encoding, rather than + * in UTF-16 code units. As a result, a path that fits within this limit may + * still be rejected by the underlying platform.

+ * + *

Conversely, any path longer than this limit is guaranteed to fail with + * at least some operating system API calls.

+ * + * @return the maximum file path length in characters. */ public int getMaxPathLength() { return maxPathLength; @@ -446,22 +438,46 @@ private boolean isIllegalFileNameChar(final int c) { } /** - * Tests if a candidate file name (without a path) such as {@code "filename.ext"} or {@code "filename"} is a - * potentially legal file name. If the file name length exceeds {@link #getMaxFileNameLength()}, or if it contains - * an illegal character then the check fails. + * Tests if a candidate file name (without a path) is a legal file name. + * + *

Takes a file name like {@code "filename.ext"} or {@code "filename"} and checks:

+ * * * @param candidate - * a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} + * A candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} * @return {@code true} if the candidate name is legal */ public boolean isLegalFileName(final CharSequence candidate) { - if (candidate == null || candidate.length() == 0 || candidate.length() > maxFileNameLength) { - return false; - } - if (isReservedFileName(candidate)) { - return false; - } - return candidate.chars().noneMatch(this::isIllegalFileNameChar); + return isLegalFileName(candidate, Charset.defaultCharset()); + } + + /** + * Tests if a candidate file name (without a path) is a legal file name. + * + *

Takes a file name like {@code "filename.ext"} or {@code "filename"} and checks:

+ * + * + * @param candidate + * A candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} + * @param charset + * The charset to use when the file name length is measured in bytes + * @return {@code true} if the candidate name is legal + * @since 2.21.0 + */ + public boolean isLegalFileName(final CharSequence candidate, final Charset charset) { + return candidate != null + && candidate.length() != 0 + && nameLengthStrategy.isWithinLimit(candidate, getMaxFileNameLength(), charset) + && !isReservedFileName(candidate) + && candidate.chars().noneMatch(this::isIllegalFileNameChar); } /** @@ -504,30 +520,224 @@ public boolean supportsDriveLetter() { } /** - * Converts a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} to a legal file - * name. Illegal characters in the candidate name are replaced by the {@code replacement} character. If the file - * name length exceeds {@link #getMaxFileNameLength()}, then the name is truncated to - * {@link #getMaxFileNameLength()}. + * Converts a candidate file name (without a path) to a legal file name. + * + *

Takes a file name like {@code "filename.ext"} or {@code "filename"} and:

+ * * * @param candidate - * a candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} + * A candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} * @param replacement * Illegal characters in the candidate name are replaced by this character * @return a String without illegal characters */ public String toLegalFileName(final String candidate, final char replacement) { + return toLegalFileName(candidate, replacement, Charset.defaultCharset()); + } + + /** + * Converts a candidate file name (without a path) to a legal file name. + * + *

Takes a file name like {@code "filename.ext"} or {@code "filename"} and:

+ * + * + * @param candidate + * A candidate file name (without a path) like {@code "filename.ext"} or {@code "filename"} + * @param replacement + * Illegal characters in the candidate name are replaced by this character + * @param charset + * The charset to use when the file name length is measured in bytes + * @return a String without illegal characters + * @since 2.21.0 + */ + public String toLegalFileName(final String candidate, final char replacement, final Charset charset) { + Objects.requireNonNull(candidate, "candidate"); + if (candidate.isEmpty()) { + throw new IllegalArgumentException("The candidate file name is empty"); + } if (isIllegalFileNameChar(replacement)) { // %s does not work properly with NUL throw new IllegalArgumentException(String.format("The replacement character '%s' cannot be one of the %s illegal characters: %s", replacement == '\0' ? "\\0" : replacement, name(), Arrays.toString(illegalFileNameChars))); } - final String truncated = candidate.length() > maxFileNameLength ? candidate.substring(0, maxFileNameLength) : candidate; + final CharSequence truncated = nameLengthStrategy.truncate(candidate, getMaxFileNameLength(), charset); final int[] array = truncated.chars().map(i -> isIllegalFileNameChar(i) ? replacement : i).toArray(); return new String(array, 0, array.length); } - CharSequence trimExtension(final CharSequence cs) { - final int index = indexOf(cs, '.', 0); - return index < 0 ? cs : cs.subSequence(0, index); + static CharSequence trimExtension(final CharSequence cs) { + final int index = indexOfFirstDot(cs); + // An initial dot is not an extension + return index < 1 ? cs : cs.subSequence(0, index); } + + static CharSequence[] splitExtension(final CharSequence value) { + final int index = indexOfFirstDot(value); + // An initial dot is not an extension + return index < 1 + ? new CharSequence[] {value, ""} + : new CharSequence[] {value.subSequence(0, index), value.subSequence(index, value.length())}; + } + + /** + * Truncates a string respecting grapheme cluster boundaries. + * + * @param value The value to truncate. + * @param limit The maximum length. + * @return The truncated value. + * @throws IllegalArgumentException If the first grapheme cluster is longer than the limit. + */ + private static CharSequence safeTruncate(final CharSequence value, final int limit) { + if (value.length() <= limit) { + return value; + } + final BreakIterator boundary = BreakIterator.getCharacterInstance(Locale.ROOT); + final String text = value.toString(); + boundary.setText(text); + final int end = boundary.preceding(limit + 1); + assert end != BreakIterator.DONE; + if (end == 0) { + final String limitMessage = limit <= 1 ? "1 character" : limit + " characters"; + throw new IllegalArgumentException("The value " + value + " can not be truncated to " + limitMessage + + " without breaking the first codepoint or grapheme cluster"); + } + return text.substring(0, end); + } + + /** + * Strategy for measuring and truncating file or path names in different units. + * Implementations measure length and can truncate to a specified limit. + */ + enum NameLengthStrategy { + /** Length measured as encoded bytes. */ + BYTES { + @Override + int getLength(final CharSequence value, final Charset charset) { + final CharsetEncoder enc = charset.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + try { + return enc.encode(CharBuffer.wrap(value)).remaining(); + } catch (CharacterCodingException e) { + // Unencodable, does not fit any byte limit. + return Integer.MAX_VALUE; + } + } + + @Override + CharSequence truncate(final CharSequence value, final int limit, final Charset charset) { + final CharsetEncoder encoder = charset.newEncoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT); + + if (!encoder.canEncode(value)) { + throw new IllegalArgumentException( + "The value " + value + " cannot be encoded using " + charset.name()); + } + + // Fast path: if even the worst-case expansion fits, we're done. + if (value.length() <= Math.floor(limit / encoder.maxBytesPerChar())) { + return value; + } + + // Slow path: encode into a fixed-size byte buffer. + // 1. Compute length of extension in bytes (if any). + final CharSequence[] parts = splitExtension(value); + final int extensionLength = getLength(parts[1], charset); + if (extensionLength > 0 && extensionLength >= limit) { + // Extension itself does not fit + throw new IllegalArgumentException( + "The extension of " + value + " is too long to fit within " + limit + " bytes"); + } + + // 2. Compute the character part that fits within the remaining byte budget. + final ByteBuffer byteBuffer = ByteBuffer.allocate(limit - extensionLength); + final CharBuffer charBuffer = CharBuffer.wrap(parts[0]); + + // Encode until the first character that would exceed the byte budget. + final CoderResult cr = encoder.encode(charBuffer, byteBuffer, true); + + if (cr.isUnderflow()) { + // Entire candidate fit within maxFileNameLength bytes. + return value; + } + + final CharSequence truncated = safeTruncate(value, charBuffer.position()); + return extensionLength == 0 ? truncated : truncated.toString() + parts[1]; + } + }, + + /** Length measured as UTF-16 code units (i.e., {@code CharSequence.length()}). */ + UTF16_CODE_UNITS { + @Override + int getLength(final CharSequence value, final Charset charset) { + return value.length(); + } + + @Override + CharSequence truncate(final CharSequence value, final int limit, final Charset charset) { + if (!UTF_16.newEncoder().canEncode(value)) { + throw new IllegalArgumentException( + "The value " + value + " can not be encoded using " + UTF_16.name()); + } + + // Fast path: no truncation needed. + if (value.length() <= limit) { + return value; + } + + // Slow path: truncate to limit. + // 1. Compute length of extension in chars (if any). + final CharSequence[] parts = splitExtension(value); + final int extensionLength = parts[1].length(); + if (extensionLength > 0 && extensionLength >= limit) { + // Extension itself does not fit + throw new IllegalArgumentException( + "The extension of " + value + " is too long to fit within " + limit + " characters"); + } + + // 2. Truncate the non-extension part and append the extension (if any). + final CharSequence truncated = safeTruncate(value, limit - extensionLength); + return extensionLength == 0 ? truncated : truncated.toString() + parts[1]; + } + }; + + /** + * Gets the measured length in this strategy’s unit. + * + * @param value The value to measure, not null. + * @param charset The charset to use when measuring in bytes. + * @return The length in this strategy’s unit. + */ + abstract int getLength(CharSequence value, Charset charset); + + /** + * Tests if the measured length is less or equal the {@code limit}. + * + * @param value The value to measure, not null. + * @param limit The limit to compare to. + * @param charset The charset to use when measuring in bytes. + * @return {@code true} if the measured length is less or equal the {@code limit}, {@code false} otherwise. + */ + final boolean isWithinLimit(final CharSequence value, final int limit, final Charset charset) { + return getLength(value, charset) <= limit; + } + + /** + * Truncates to {@code limit} in this strategy’s unit (no-op if already within limit). + * + * @param value The value to truncate, not null. + * @param limit The limit to truncate to. + * @param charset The charset to use when measuring in bytes. + * @return The truncated value, not null. + */ + abstract CharSequence truncate(CharSequence value, int limit, Charset charset); + } + } diff --git a/src/test/java/org/apache/commons/io/FileSystemTest.java b/src/test/java/org/apache/commons/io/FileSystemTest.java index d061a618e01..0a3c0524ab7 100644 --- a/src/test/java/org/apache/commons/io/FileSystemTest.java +++ b/src/test/java/org/apache/commons/io/FileSystemTest.java @@ -17,21 +17,106 @@ package org.apache.commons.io; +import static java.nio.charset.StandardCharsets.ISO_8859_1; +import static java.nio.charset.StandardCharsets.US_ASCII; +import static java.nio.charset.StandardCharsets.UTF_8; +import static org.apache.commons.io.FileSystem.NameLengthStrategy.BYTES; +import static org.apache.commons.io.FileSystem.NameLengthStrategy.UTF16_CODE_UNITS; +import static org.apache.commons.lang3.StringUtils.repeat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.stream.Stream; + +import org.apache.commons.io.FileSystem.NameLengthStrategy; +import org.apache.commons.lang3.JavaVersion; +import org.apache.commons.lang3.SystemProperties; import org.apache.commons.lang3.SystemUtils; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.condition.EnabledOnOs; -import org.junit.jupiter.api.condition.OS; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; /** * Tests {@link FileSystem}. */ class FileSystemTest { + /** A single ASCII character that encodes to 1 UTF-8 byte. */ + private static final String CHAR_UTF8_1B = "a"; + + /** A single Unicode character that encodes to 2 UTF-8 bytes. */ + private static final String CHAR_UTF8_2B = "é"; + + /** A single Unicode character that encodes to 3 UTF-8 bytes. */ + private static final String CHAR_UTF8_3B = "★"; + + /** A single Unicode codepoint that encodes to 2 UTF-16 code units and 4 UTF-8 bytes. */ + private static final String CHAR_UTF8_4B = "😀"; + + /** + * A grapheme cluster that encodes to 69 UTF-8 bytes and 31 UTF-16 code units: 👩🏻‍🦰‍👨🏿‍🦲‍👧🏽‍🦱‍👦🏼‍🦳 + *

+ * This should be treated as a single character in JDK 20+ for truncation purposes, + * even if it contains parts that have a meaning on their own. + *

+ * + */ + private static final String CHAR_UTF8_69B = + // woman + light skin + ZWJ + red hair = 15 bytes + "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB0" + // ZWJ = 3 bytes + + "\u200D" + // man + dark skin + ZWJ + bald = 15 bytes + + "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB2" + // ZWJ = 3 bytes + + "\u200D" + // girl + medium skin + ZWJ + curly hair = 15 bytes + + "\uD83D\uDC67\uD83C\uDFFD\u200D\uD83E\uDDB1" + // ZWJ = 3 bytes + + "\u200D" + // boy + medium-light skin + ZWJ + white hair = 15 bytes + + "\uD83D\uDC66\uD83C\uDFFC\u200D\uD83E\uDDB3"; + + /** File name of 255 bytes and 255 UTF-16 code units. */ + private static final String FILE_NAME_255BYTES_UTF8_1B = repeat(CHAR_UTF8_1B, 255); + + /** File name of 255 bytes and 128 UTF-16 code units. */ + private static final String FILE_NAME_255BYTES_UTF8_2B = repeat(CHAR_UTF8_2B, 127) + CHAR_UTF8_1B; + + /** File name of 255 bytes and 85 UTF-16 code units. */ + private static final String FILE_NAME_255BYTES_UTF8_3B = repeat(CHAR_UTF8_3B, 85); + + /** File name of 255 bytes and 64 UTF-16 code units. */ + private static final String FILE_NAME_255BYTES_UTF8_4B = repeat(CHAR_UTF8_4B, 63) + CHAR_UTF8_3B; + + /** File name of 255 bytes and 255 UTF-16 code units. */ + private static final String FILE_NAME_255CHARS_UTF8_1B = FILE_NAME_255BYTES_UTF8_1B; + + /** File name of 510 bytes and 255 UTF-16 code units. */ + private static final String FILE_NAME_255CHARS_UTF8_2B = repeat(CHAR_UTF8_2B, 255); + + /** File name of 765 bytes and 255 UTF-16 code units. */ + private static final String FILE_NAME_255CHARS_UTF8_3B = repeat(CHAR_UTF8_3B, 255); + + /** File name of 511 bytes and 255 UTF-16 code units. */ + private static final String FILE_NAME_255CHARS_UTF8_4B = repeat(CHAR_UTF8_4B, 127) + CHAR_UTF8_3B; + @Test void testGetBlockSize() { assertTrue(FileSystem.getCurrent().getBlockSize() >= 0); @@ -57,19 +142,63 @@ void testGetIllegalFileNameChars() { } @Test - void testIsLegalName() { - for (final FileSystem fs : FileSystem.values()) { - assertFalse(fs.isLegalFileName(""), fs.name()); // Empty is always illegal - assertFalse(fs.isLegalFileName(null), fs.name()); // null is always illegal - assertFalse(fs.isLegalFileName("\0"), fs.name()); // Assume NUL is always illegal - assertTrue(fs.isLegalFileName("0"), fs.name()); // Assume simple name always legal - for (final String candidate : fs.getReservedFileNames()) { - // Reserved file names are not legal - assertFalse(fs.isLegalFileName(candidate), candidate); - } + void testGetNameSeparator() { + final FileSystem current = FileSystem.getCurrent(); + assertEquals(SystemProperties.getFileSeparator(), Character.toString(current.getNameSeparator())); + } + + @ParameterizedTest + @EnumSource(FileSystem.class) + void testIsLegalName(final FileSystem fs) { + assertFalse(fs.isLegalFileName(""), fs.name()); // Empty is always illegal + assertFalse(fs.isLegalFileName(null), fs.name()); // null is always illegal + assertFalse(fs.isLegalFileName("\0"), fs.name()); // Assume NUL is always illegal + assertTrue(fs.isLegalFileName("0"), fs.name()); // Assume simple name always legal + for (final String candidate : fs.getReservedFileNames()) { + // Reserved file names are not legal + assertFalse(fs.isLegalFileName(candidate), candidate); } } + static Stream testIsLegalName_Length() { + return Stream.of( + Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255BYTES_UTF8_1B, 4), UTF_8), + Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255BYTES_UTF8_2B, 4), UTF_8), + Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255BYTES_UTF8_3B, 4), UTF_8), + Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255BYTES_UTF8_4B, 4), UTF_8), + Arguments.of(FileSystem.LINUX, FILE_NAME_255BYTES_UTF8_1B, UTF_8), + Arguments.of(FileSystem.LINUX, FILE_NAME_255BYTES_UTF8_2B, UTF_8), + Arguments.of(FileSystem.LINUX, FILE_NAME_255BYTES_UTF8_3B, UTF_8), + Arguments.of(FileSystem.LINUX, FILE_NAME_255BYTES_UTF8_4B, UTF_8), + Arguments.of(FileSystem.MAC_OSX, FILE_NAME_255BYTES_UTF8_1B, UTF_8), + Arguments.of(FileSystem.MAC_OSX, FILE_NAME_255BYTES_UTF8_2B, UTF_8), + Arguments.of(FileSystem.MAC_OSX, FILE_NAME_255BYTES_UTF8_3B, UTF_8), + Arguments.of(FileSystem.MAC_OSX, FILE_NAME_255BYTES_UTF8_4B, UTF_8), + Arguments.of(FileSystem.WINDOWS, FILE_NAME_255CHARS_UTF8_1B, UTF_8), + Arguments.of(FileSystem.WINDOWS, FILE_NAME_255CHARS_UTF8_2B, UTF_8), + Arguments.of(FileSystem.WINDOWS, FILE_NAME_255CHARS_UTF8_3B, UTF_8), + Arguments.of(FileSystem.WINDOWS, FILE_NAME_255CHARS_UTF8_4B, UTF_8), + // Repeat some tests with other encodings for GENERIC and LINUX + Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255BYTES_UTF8_1B, 4), US_ASCII), + Arguments.of(FileSystem.GENERIC, repeat(CHAR_UTF8_2B, 1020), ISO_8859_1), + Arguments.of(FileSystem.LINUX, FILE_NAME_255BYTES_UTF8_1B, US_ASCII), + Arguments.of(FileSystem.LINUX, repeat(CHAR_UTF8_2B, 255), ISO_8859_1)); + } + + @ParameterizedTest(name = "{index}: {0} with charset {2}") + @MethodSource + void testIsLegalName_Length(FileSystem fs, String nameAtLimit, Charset charset) { + assertTrue(fs.isLegalFileName(nameAtLimit, charset), fs.name() + " length at limit"); + final String nameOverLimit = nameAtLimit + "a"; + assertFalse(fs.isLegalFileName(nameOverLimit, charset), fs.name() + " length over limit"); + } + + @Test + void testIsLegalName_Encoding() { + assertFalse(FileSystem.GENERIC.isLegalFileName(FILE_NAME_255BYTES_UTF8_3B, US_ASCII), "US-ASCII cannot represent all chars"); + assertTrue(FileSystem.GENERIC.isLegalFileName(FILE_NAME_255BYTES_UTF8_3B, UTF_8), "UTF-8 can represent all chars"); + } + @Test void testIsReservedFileName() { for (final FileSystem fs : FileSystem.values()) { @@ -80,7 +209,6 @@ void testIsReservedFileName() { } @Test - @EnabledOnOs(OS.WINDOWS) void testIsReservedFileNameOnWindows() { final FileSystem fs = FileSystem.WINDOWS; for (final String candidate : fs.getReservedFileNames()) { @@ -128,6 +256,90 @@ void testSorted() { } } + @Test + void testMaxNameLength_MatchesRealSystem(@TempDir Path tempDir) { + final FileSystem fs = FileSystem.getCurrent(); + final String[] validNames; + switch (fs) { + case MAC_OSX: + case LINUX: + // Names with 255 UTF-8 bytes are legal + validNames = new String[] { + FILE_NAME_255BYTES_UTF8_1B, + FILE_NAME_255BYTES_UTF8_2B, + FILE_NAME_255BYTES_UTF8_3B, + FILE_NAME_255BYTES_UTF8_4B + }; + break; + case WINDOWS: + // Names with 255 UTF-16 code units are legal + validNames = new String[] { + FILE_NAME_255CHARS_UTF8_1B, + FILE_NAME_255CHARS_UTF8_2B, + FILE_NAME_255CHARS_UTF8_3B, + FILE_NAME_255CHARS_UTF8_4B + }; + break; + default: + throw new IllegalStateException("Unexpected value: " + fs); + } + int failures = 0; + for (final String fileName : validNames) { + // 1) OS should accept names at the documented limit. + assertDoesNotThrow( + () -> createAndDelete(tempDir, fileName), "OS should accept max-length name: " + fileName); + + // 2) Library should consider them legal. + assertTrue(fs.isLegalFileName(fileName, UTF_8), "Commons IO should accept max-length name: " + fileName); + + // 3) For “one over” the limit: Commons IO must reject; OS may or may not enforce strictly. + final String tooLongName = fileName + "a"; + + // Library contract: must be illegal. + assertFalse( + fs.isLegalFileName(tooLongName, UTF_8), "Commons IO should reject too-long name: " + tooLongName); + + // OS behavior: may or may not reject. + try { + createAndDelete(tempDir, tooLongName); + } catch (final Throwable e) { + failures++; + assertInstanceOf(IOException.class, e, "OS rejects too-long name"); + } + } + // On Linux and Windows the API and the filesystem measure name length + // in the same unit as the underlying limit (255 bytes on Linux/most POSIX, + // 255 UTF-16 code units on Windows). + // So all “too-long” variants should fail. + // + // macOS is trickier because the API and filesystem limits don’t always match: + // + // - POSIX API layer (getdirentries/readdir): 1023 bytes per component since macOS 10.5. + // https://man.freebsd.org/cgi/man.cgi?query=dir&sektion=5&apropos=0&manpath=macOS+15.6 + // - HFS+: enforces 255 UTF-16 code units per component. + // - APFS: enforces 255 UTF-8 bytes per component. + // + // Because of this mismatch, depending on which filesystem is mounted, + // either all or only FILE_NAME_255BYTES_UTF8_1B + "a" will be rejected. + if (SystemUtils.IS_OS_MAC_OSX) { + assertTrue(failures >= 1, "Expected at least one too-long name rejected, got " + failures); + } else { + assertEquals(4, failures, "All too-long names were rejected"); + } + } + + private static void createAndDelete(Path tempDir, String fileName) throws IOException { + final Path filePath = tempDir.resolve(fileName); + Files.createFile(filePath); + try (Stream files = Files.list(tempDir)) { + final boolean found = files.anyMatch(filePath::equals); + if (!found) { + throw new FileNotFoundException(fileName + " not found in " + tempDir); + } + } + Files.delete(filePath); + } + @Test void testSupportsDriveLetter() { assertTrue(FileSystem.WINDOWS.supportsDriveLetter()); @@ -156,5 +368,126 @@ void testToLegalFileNameWindows() { for (char i = '0'; i < '9'; i++) { assertEquals(i, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0)); } + // Null and empty + assertThrows(NullPointerException.class, () -> fs.toLegalFileName(null, '_')); + assertThrows(IllegalArgumentException.class, () -> fs.toLegalFileName("", '_')); + // Illegal replacement + assertThrows(IllegalArgumentException.class, () -> fs.toLegalFileName("test", '\0')); + assertThrows(IllegalArgumentException.class, () -> fs.toLegalFileName("test", ':')); + } + + static Stream testNameLengthStrategyTruncate_Succeeds() { + // The grapheme cluster CHAR_UTF8_69B is treated as a single character in JDK 20+, + final String woman; + final String redHeadWoman; + if (SystemUtils.isJavaVersionAtMost(JavaVersion.JAVA_19)) { + woman = CHAR_UTF8_69B.substring(0, 2); // 👩 + redHeadWoman = CHAR_UTF8_69B.substring(0, 7); // 👩🏻‍🦰 + } else { + woman = ""; + redHeadWoman = ""; + } + return Stream.of( + // Truncation by bytes + // ------------------- + // + // Empty + Arguments.of(BYTES, 0, "", ""), + // Simple name without truncation + Arguments.of(BYTES, 10, "simple.txt", "simple.txt"), + // Name starting with dot + Arguments.of(BYTES, 10, "." + repeat(CHAR_UTF8_1B, 10), "." + repeat(CHAR_UTF8_1B, 9)), + Arguments.of(BYTES, 20, "." + repeat(CHAR_UTF8_2B, 10), "." + repeat(CHAR_UTF8_2B, 9)), + Arguments.of(BYTES, 30, "." + repeat(CHAR_UTF8_3B, 10), "." + repeat(CHAR_UTF8_3B, 9)), + Arguments.of(BYTES, 40, "." + repeat(CHAR_UTF8_4B, 10), "." + repeat(CHAR_UTF8_4B, 9)), + // Names with extensions + Arguments.of(BYTES, 13, repeat(CHAR_UTF8_1B, 10) + ".txt", repeat(CHAR_UTF8_1B, 9) + ".txt"), + Arguments.of(BYTES, 23, repeat(CHAR_UTF8_2B, 10) + ".txt", repeat(CHAR_UTF8_2B, 9) + ".txt"), + Arguments.of(BYTES, 33, repeat(CHAR_UTF8_3B, 10) + ".txt", repeat(CHAR_UTF8_3B, 9) + ".txt"), + Arguments.of(BYTES, 43, repeat(CHAR_UTF8_4B, 10) + ".txt", repeat(CHAR_UTF8_4B, 9) + ".txt"), + // Names without extensions + Arguments.of(BYTES, 1, CHAR_UTF8_1B, CHAR_UTF8_1B), + Arguments.of(BYTES, 2, CHAR_UTF8_2B, CHAR_UTF8_2B), + Arguments.of(BYTES, 3, CHAR_UTF8_3B, CHAR_UTF8_3B), + Arguments.of(BYTES, 4, CHAR_UTF8_4B, CHAR_UTF8_4B), + Arguments.of(BYTES, 9, repeat(CHAR_UTF8_1B, 10), repeat(CHAR_UTF8_1B, 9)), + Arguments.of(BYTES, 19, repeat(CHAR_UTF8_2B, 10), repeat(CHAR_UTF8_2B, 9)), + Arguments.of(BYTES, 29, repeat(CHAR_UTF8_3B, 10), repeat(CHAR_UTF8_3B, 9)), + Arguments.of(BYTES, 39, repeat(CHAR_UTF8_4B, 10), repeat(CHAR_UTF8_4B, 9)), + // Grapheme cluster + Arguments.of(BYTES, 69, CHAR_UTF8_69B, CHAR_UTF8_69B), + // Will not cut 4 or 15 bytes of the grapheme cluster + Arguments.of(BYTES, 69 + 4, repeat(CHAR_UTF8_69B, 2), CHAR_UTF8_69B + woman), + Arguments.of(BYTES, 69 + 15, repeat(CHAR_UTF8_69B, 2), CHAR_UTF8_69B + redHeadWoman), + // Truncation by UTF-16 code units + // ------------------------------- + // Empty + Arguments.of(UTF16_CODE_UNITS, 0, "", ""), + // Simple name without truncation + Arguments.of(UTF16_CODE_UNITS, 10, "simple.txt", "simple.txt"), + // Name starting with dot + Arguments.of(UTF16_CODE_UNITS, 10, "." + repeat(CHAR_UTF8_1B, 10), "." + repeat(CHAR_UTF8_1B, 9)), + Arguments.of(UTF16_CODE_UNITS, 10, "." + repeat(CHAR_UTF8_2B, 10), "." + repeat(CHAR_UTF8_2B, 9)), + Arguments.of(UTF16_CODE_UNITS, 10, "." + repeat(CHAR_UTF8_3B, 10), "." + repeat(CHAR_UTF8_3B, 9)), + Arguments.of(UTF16_CODE_UNITS, 20, "." + repeat(CHAR_UTF8_4B, 10), "." + repeat(CHAR_UTF8_4B, 9)), + // Names with extensions + Arguments.of(UTF16_CODE_UNITS, 13, repeat(CHAR_UTF8_1B, 10) + ".txt", repeat(CHAR_UTF8_1B, 9) + ".txt"), + Arguments.of(UTF16_CODE_UNITS, 13, repeat(CHAR_UTF8_2B, 10) + ".txt", repeat(CHAR_UTF8_2B, 9) + ".txt"), + Arguments.of(UTF16_CODE_UNITS, 13, repeat(CHAR_UTF8_3B, 10) + ".txt", repeat(CHAR_UTF8_3B, 9) + ".txt"), + Arguments.of(UTF16_CODE_UNITS, 23, repeat(CHAR_UTF8_4B, 10) + ".txt", repeat(CHAR_UTF8_4B, 9) + ".txt"), + // Names without extensions + Arguments.of(UTF16_CODE_UNITS, 1, CHAR_UTF8_1B, CHAR_UTF8_1B), + Arguments.of(UTF16_CODE_UNITS, 1, CHAR_UTF8_2B, CHAR_UTF8_2B), + Arguments.of(UTF16_CODE_UNITS, 1, CHAR_UTF8_3B, CHAR_UTF8_3B), + Arguments.of(UTF16_CODE_UNITS, 2, CHAR_UTF8_4B, CHAR_UTF8_4B), + Arguments.of(UTF16_CODE_UNITS, 9, repeat(CHAR_UTF8_1B, 10), repeat(CHAR_UTF8_1B, 9)), + Arguments.of(UTF16_CODE_UNITS, 9, repeat(CHAR_UTF8_2B, 10), repeat(CHAR_UTF8_2B, 9)), + Arguments.of(UTF16_CODE_UNITS, 9, repeat(CHAR_UTF8_3B, 10), repeat(CHAR_UTF8_3B, 9)), + Arguments.of(UTF16_CODE_UNITS, 19, repeat(CHAR_UTF8_4B, 10), repeat(CHAR_UTF8_4B, 9)), + // Grapheme cluster + Arguments.of(UTF16_CODE_UNITS, 31, CHAR_UTF8_69B, CHAR_UTF8_69B), + // Will not cut 2 or 7 UTF-16 code units of the grapheme cluster + Arguments.of(UTF16_CODE_UNITS, 31 + 2, repeat(CHAR_UTF8_69B, 2), CHAR_UTF8_69B + woman), + Arguments.of(UTF16_CODE_UNITS, 31 + 7, repeat(CHAR_UTF8_69B, 2), CHAR_UTF8_69B + redHeadWoman)); + } + + @ParameterizedTest(name = "{index}: {0} truncates {1} to {2}") + @MethodSource + void testNameLengthStrategyTruncate_Succeeds(NameLengthStrategy strategy, int limit, String input, String expected) { + final CharSequence out = strategy.truncate(input, limit, UTF_8); + assertEquals(expected, out.toString(), strategy.name() + " truncates to limit"); + } + + static Stream testNameLengthStrategyTruncate_Throws() { + final Stream common = Stream.of( + // Encoding issues + Arguments.of(BYTES, 10, "café", US_ASCII, "US-ASCII"), + Arguments.of(UTF16_CODE_UNITS, 10, "\uD800.txt", UTF_8, "UTF-16"), + Arguments.of(UTF16_CODE_UNITS, 10, "\uDC00.txt", UTF_8, "UTF-16"), + // Extension too long + Arguments.of(BYTES, 4, "a.txt", UTF_8, "extension"), + Arguments.of(UTF16_CODE_UNITS, 4, "a.txt", UTF_8, "extension"), + // Limit too small + Arguments.of(BYTES, 3, CHAR_UTF8_4B, UTF_8, "truncated to 1 character"), + Arguments.of(UTF16_CODE_UNITS, 1, CHAR_UTF8_4B, UTF_8, "truncated to 1 character")); + return SystemUtils.isJavaVersionAtMost(JavaVersion.JAVA_19) + ? common + : Stream.concat( + common, + // In JDK 20+ the grapheme cluster CHAR_UTF8_69B is treated as a single character, + // so cannot be truncated to 2 or 7 code units + Stream.of( + Arguments.of(BYTES, 68, CHAR_UTF8_69B, UTF_8, "truncated to 29 characters"), + Arguments.of(UTF16_CODE_UNITS, 30, CHAR_UTF8_69B, UTF_8, "truncated to 30 characters"))); + } + + @ParameterizedTest(name = "{index}: {0} truncates {2} with limit {1} throws") + @MethodSource + void testNameLengthStrategyTruncate_Throws( + NameLengthStrategy strategy, int limit, String input, Charset charset, String message) { + final IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> strategy.truncate(input, limit, charset)); + final String exMessage = ex.getMessage(); + assertTrue(exMessage.contains(message), "ex message contains " + message + ": " + exMessage); } }