View Javadoc
1   /*
2    * Licensed to the Apache Software Foundation (ASF) under one or more
3    * contributor license agreements.  See the NOTICE file distributed with
4    * this work for additional information regarding copyright ownership.
5    * The ASF licenses this file to You under the Apache License, Version 2.0
6    * (the "License"); you may not use this file except in compliance with
7    * the License.  You may obtain a copy of the License at
8    *
9    *      https://www.apache.org/licenses/LICENSE-2.0
10   *
11   * Unless required by applicable law or agreed to in writing, software
12   * distributed under the License is distributed on an "AS IS" BASIS,
13   * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14   * See the License for the specific language governing permissions and
15   * limitations under the License.
16   */
17  
18  package org.apache.commons.io;
19  
20  import static java.nio.charset.StandardCharsets.ISO_8859_1;
21  import static java.nio.charset.StandardCharsets.US_ASCII;
22  import static java.nio.charset.StandardCharsets.UTF_8;
23  import static org.apache.commons.io.FileSystem.NameLengthStrategy.BYTES;
24  import static org.apache.commons.io.FileSystem.NameLengthStrategy.UTF16_CODE_UNITS;
25  import static org.apache.commons.lang3.StringUtils.repeat;
26  import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
27  import static org.junit.jupiter.api.Assertions.assertEquals;
28  import static org.junit.jupiter.api.Assertions.assertFalse;
29  import static org.junit.jupiter.api.Assertions.assertInstanceOf;
30  import static org.junit.jupiter.api.Assertions.assertNotNull;
31  import static org.junit.jupiter.api.Assertions.assertNotSame;
32  import static org.junit.jupiter.api.Assertions.assertThrows;
33  import static org.junit.jupiter.api.Assertions.assertTrue;
34  import static org.junit.jupiter.api.Assumptions.assumeTrue;
35  
36  import java.io.BufferedReader;
37  import java.io.FileNotFoundException;
38  import java.io.IOException;
39  import java.io.StringReader;
40  import java.nio.charset.Charset;
41  import java.nio.charset.StandardCharsets;
42  import java.nio.file.Files;
43  import java.nio.file.Path;
44  import java.util.Objects;
45  import java.util.stream.Stream;
46  
47  import javax.xml.parsers.DocumentBuilder;
48  import javax.xml.parsers.DocumentBuilderFactory;
49  import javax.xml.parsers.ParserConfigurationException;
50  
51  import org.apache.commons.io.FileSystem.NameLengthStrategy;
52  import org.apache.commons.lang3.JavaVersion;
53  import org.apache.commons.lang3.StringUtils;
54  import org.apache.commons.lang3.SystemProperties;
55  import org.apache.commons.lang3.SystemUtils;
56  import org.junit.jupiter.api.Test;
57  import org.junit.jupiter.api.io.TempDir;
58  import org.junit.jupiter.params.ParameterizedTest;
59  import org.junit.jupiter.params.provider.Arguments;
60  import org.junit.jupiter.params.provider.EnumSource;
61  import org.junit.jupiter.params.provider.MethodSource;
62  import org.w3c.dom.Document;
63  import org.xml.sax.InputSource;
64  import org.xml.sax.SAXException;
65  
66  /**
67   * Tests {@link FileSystem}.
68   */
69  class FileSystemTest {
70  
71      /** A single ASCII character that encodes to 1 UTF-8 byte. */
72      private static final String CHAR_UTF8_1B = "a";
73  
74      /** A single Unicode character that encodes to 2 UTF-8 bytes. */
75      private static final String CHAR_UTF8_2B = "Γ©";
76  
77      /** A single Unicode character that encodes to 3 UTF-8 bytes. */
78      private static final String CHAR_UTF8_3B = "β˜…";
79  
80      /** A single Unicode code point that encodes to 2 UTF-16 code units and 4 UTF-8 bytes. */
81      private static final String CHAR_UTF8_4B = "πŸ˜€";
82  
83      /**
84       * A grapheme cluster that encodes to 69 UTF-8 bytes and 31 UTF-16 code units: πŸ‘©πŸ»β€πŸ¦°β€πŸ‘¨πŸΏβ€πŸ¦²β€πŸ‘§πŸ½β€πŸ¦±β€πŸ‘¦πŸΌβ€πŸ¦³
85       * <p>
86       *     This should be treated as a single character in JDK 20+ for truncation purposes,
87       *     even if it contains parts that have a meaning on their own.
88       * </p>
89       * <ul>
90       *     <li>{@code πŸ‘©}: 4 UTF-8 bytes and 2 UTF-16 code points.</li>
91       *     <li>{@code πŸ‘©πŸ»β€πŸ¦°}: 15 UTF-8 bytes and 7 UTF-16 code points.</li>
92       * </ul>
93       */
94      private static final String CHAR_UTF8_69B =
95              // woman + light skin + ZWJ + red hair = 15 bytes
96              "\uD83D\uDC69\uD83C\uDFFB\u200D\uD83E\uDDB0"
97                      // ZWJ = 3 bytes
98                      + "\u200D"
99                      // man + dark skin + ZWJ + bald = 15 bytes
100                     + "\uD83D\uDC68\uD83C\uDFFF\u200D\uD83E\uDDB2"
101                     // ZWJ = 3 bytes
102                     + "\u200D"
103                     // girl + medium skin + ZWJ + curly hair = 15 bytes
104                     + "\uD83D\uDC67\uD83C\uDFFD\u200D\uD83E\uDDB1"
105                     // ZWJ = 3 bytes
106                     + "\u200D"
107                     // boy + medium-light skin + ZWJ + white hair = 15 bytes
108                     + "\uD83D\uDC66\uD83C\uDFFC\u200D\uD83E\uDDB3";
109 
110     /** File name of 255 bytes and 255 UTF-16 code units. */
111     private static final String FILE_NAME_255_BYTES_UTF8_1B = repeat(CHAR_UTF8_1B, 255);
112 
113     /** File name of 255 bytes and 128 UTF-16 code units. */
114     private static final String FILE_NAME_255_BYTES_UTF8_2B = repeat(CHAR_UTF8_2B, 127) + CHAR_UTF8_1B;
115 
116     /** File name of 255 bytes and 85 UTF-16 code units. */
117     private static final String FILE_NAME_255_BYTES_UTF8_3B = repeat(CHAR_UTF8_3B, 85);
118 
119     /** File name of 255 bytes and 64 UTF-16 code units. */
120     private static final String FILE_NAME_255_BYTES_UTF8_4B = repeat(CHAR_UTF8_4B, 63) + CHAR_UTF8_3B;
121 
122     /** File name of 255 bytes and 255 UTF-16 code units. */
123     private static final String FILE_NAME_255_CHARS_UTF8_1B = FILE_NAME_255_BYTES_UTF8_1B;
124 
125     /** File name of 510 bytes and 255 UTF-16 code units. */
126     private static final String FILE_NAME_255_CHARS_UTF8_2B = repeat(CHAR_UTF8_2B, 255);
127 
128     /** File name of 765 bytes and 255 UTF-16 code units. */
129     private static final String FILE_NAME_255_CHARS_UTF8_3B = repeat(CHAR_UTF8_3B, 255);
130 
131     /** File name of 511 bytes and 255 UTF-16 code units. */
132     private static final String FILE_NAME_255_CHARS_UTF8_4B = repeat(CHAR_UTF8_4B, 127) + CHAR_UTF8_3B;
133 
134     private static void createAndDelete(final Path tempDir, final String fileName) throws IOException {
135         final Path filePath = tempDir.resolve(fileName);
136         Files.createFile(filePath);
137         try (Stream<Path> files = Files.list(tempDir)) {
138             final boolean found = files.anyMatch(filePath::equals);
139             if (!found) {
140                 throw new FileNotFoundException(fileName + " not found in " + tempDir);
141             }
142         }
143         Files.delete(filePath);
144     }
145 
146     static Stream<Arguments> testIsLegalName_Length() {
147         return Stream.of(
148                 Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255_BYTES_UTF8_1B, 4), UTF_8),
149                 Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255_BYTES_UTF8_2B, 4), UTF_8),
150                 Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255_BYTES_UTF8_3B, 4), UTF_8),
151                 Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255_BYTES_UTF8_4B, 4), UTF_8),
152                 Arguments.of(FileSystem.LINUX, FILE_NAME_255_BYTES_UTF8_1B, UTF_8),
153                 Arguments.of(FileSystem.LINUX, FILE_NAME_255_BYTES_UTF8_2B, UTF_8),
154                 Arguments.of(FileSystem.LINUX, FILE_NAME_255_BYTES_UTF8_3B, UTF_8),
155                 Arguments.of(FileSystem.LINUX, FILE_NAME_255_BYTES_UTF8_4B, UTF_8),
156                 Arguments.of(FileSystem.MAC_OSX, FILE_NAME_255_BYTES_UTF8_1B, UTF_8),
157                 Arguments.of(FileSystem.MAC_OSX, FILE_NAME_255_BYTES_UTF8_2B, UTF_8),
158                 Arguments.of(FileSystem.MAC_OSX, FILE_NAME_255_BYTES_UTF8_3B, UTF_8),
159                 Arguments.of(FileSystem.MAC_OSX, FILE_NAME_255_BYTES_UTF8_4B, UTF_8),
160                 Arguments.of(FileSystem.WINDOWS, FILE_NAME_255_CHARS_UTF8_1B, UTF_8),
161                 Arguments.of(FileSystem.WINDOWS, FILE_NAME_255_CHARS_UTF8_2B, UTF_8),
162                 Arguments.of(FileSystem.WINDOWS, FILE_NAME_255_CHARS_UTF8_3B, UTF_8),
163                 Arguments.of(FileSystem.WINDOWS, FILE_NAME_255_CHARS_UTF8_4B, UTF_8),
164                 // Repeat some tests with other encodings for GENERIC and LINUX
165                 Arguments.of(FileSystem.GENERIC, repeat(FILE_NAME_255_BYTES_UTF8_1B, 4), US_ASCII),
166                 Arguments.of(FileSystem.GENERIC, repeat(CHAR_UTF8_2B, 1020), ISO_8859_1),
167                 Arguments.of(FileSystem.LINUX, FILE_NAME_255_BYTES_UTF8_1B, US_ASCII),
168                 Arguments.of(FileSystem.LINUX, repeat(CHAR_UTF8_2B, 255), ISO_8859_1));
169     }
170 
171     static Stream<Arguments> testNameLengthStrategyTruncate_Succeeds() {
172         // The grapheme cluster CHAR_UTF8_69B is treated as a single character in JDK 20+,
173         final String woman;
174         final String redHeadWoman;
175         if (SystemUtils.isJavaVersionAtMost(JavaVersion.JAVA_19)) {
176             woman = CHAR_UTF8_69B.substring(0, 2); // πŸ‘©
177             redHeadWoman = CHAR_UTF8_69B.substring(0, 7); // πŸ‘©πŸ»β€πŸ¦°
178         } else {
179             woman = "";
180             redHeadWoman = "";
181         }
182         return Stream.of(
183                 // Truncation by bytes
184                 // -------------------
185                 //
186                 // Empty
187                 Arguments.of(BYTES, 0, "", ""),
188                 // Simple name without truncation
189                 Arguments.of(BYTES, 10, "simple.txt", "simple.txt"),
190                 // Name starting with dot
191                 Arguments.of(BYTES, 10, "." + repeat(CHAR_UTF8_1B, 10), "." + repeat(CHAR_UTF8_1B, 9)),
192                 Arguments.of(BYTES, 20, "." + repeat(CHAR_UTF8_2B, 10), "." + repeat(CHAR_UTF8_2B, 9)),
193                 Arguments.of(BYTES, 30, "." + repeat(CHAR_UTF8_3B, 10), "." + repeat(CHAR_UTF8_3B, 9)),
194                 Arguments.of(BYTES, 40, "." + repeat(CHAR_UTF8_4B, 10), "." + repeat(CHAR_UTF8_4B, 9)),
195                 // Names with extensions
196                 Arguments.of(BYTES, 13, repeat(CHAR_UTF8_1B, 10) + ".txt", repeat(CHAR_UTF8_1B, 9) + ".txt"),
197                 Arguments.of(BYTES, 23, repeat(CHAR_UTF8_2B, 10) + ".txt", repeat(CHAR_UTF8_2B, 9) + ".txt"),
198                 Arguments.of(BYTES, 33, repeat(CHAR_UTF8_3B, 10) + ".txt", repeat(CHAR_UTF8_3B, 9) + ".txt"),
199                 Arguments.of(BYTES, 43, repeat(CHAR_UTF8_4B, 10) + ".txt", repeat(CHAR_UTF8_4B, 9) + ".txt"),
200                 // Names without extensions
201                 Arguments.of(BYTES, 1, CHAR_UTF8_1B, CHAR_UTF8_1B),
202                 Arguments.of(BYTES, 2, CHAR_UTF8_2B, CHAR_UTF8_2B),
203                 Arguments.of(BYTES, 3, CHAR_UTF8_3B, CHAR_UTF8_3B),
204                 Arguments.of(BYTES, 4, CHAR_UTF8_4B, CHAR_UTF8_4B),
205                 Arguments.of(BYTES, 9, repeat(CHAR_UTF8_1B, 10), repeat(CHAR_UTF8_1B, 9)),
206                 Arguments.of(BYTES, 19, repeat(CHAR_UTF8_2B, 10), repeat(CHAR_UTF8_2B, 9)),
207                 Arguments.of(BYTES, 29, repeat(CHAR_UTF8_3B, 10), repeat(CHAR_UTF8_3B, 9)),
208                 Arguments.of(BYTES, 39, repeat(CHAR_UTF8_4B, 10), repeat(CHAR_UTF8_4B, 9)),
209                 // Grapheme cluster
210                 Arguments.of(BYTES, 69, CHAR_UTF8_69B, CHAR_UTF8_69B),
211                 // Will not cut 4 or 15 bytes of the grapheme cluster
212                 Arguments.of(BYTES, 69 + 4, repeat(CHAR_UTF8_69B, 2), CHAR_UTF8_69B + woman),
213                 Arguments.of(BYTES, 69 + 15, repeat(CHAR_UTF8_69B, 2), CHAR_UTF8_69B + redHeadWoman),
214                 // Truncation by UTF-16 code units
215                 // -------------------------------
216                 // Empty
217                 Arguments.of(UTF16_CODE_UNITS, 0, "", ""),
218                 // Simple name without truncation
219                 Arguments.of(UTF16_CODE_UNITS, 10, "simple.txt", "simple.txt"),
220                 // Name starting with dot
221                 Arguments.of(UTF16_CODE_UNITS, 10, "." + repeat(CHAR_UTF8_1B, 10), "." + repeat(CHAR_UTF8_1B, 9)),
222                 Arguments.of(UTF16_CODE_UNITS, 10, "." + repeat(CHAR_UTF8_2B, 10), "." + repeat(CHAR_UTF8_2B, 9)),
223                 Arguments.of(UTF16_CODE_UNITS, 10, "." + repeat(CHAR_UTF8_3B, 10), "." + repeat(CHAR_UTF8_3B, 9)),
224                 Arguments.of(UTF16_CODE_UNITS, 20, "." + repeat(CHAR_UTF8_4B, 10), "." + repeat(CHAR_UTF8_4B, 9)),
225                 // Names with extensions
226                 Arguments.of(UTF16_CODE_UNITS, 13, repeat(CHAR_UTF8_1B, 10) + ".txt", repeat(CHAR_UTF8_1B, 9) + ".txt"),
227                 Arguments.of(UTF16_CODE_UNITS, 13, repeat(CHAR_UTF8_2B, 10) + ".txt", repeat(CHAR_UTF8_2B, 9) + ".txt"),
228                 Arguments.of(UTF16_CODE_UNITS, 13, repeat(CHAR_UTF8_3B, 10) + ".txt", repeat(CHAR_UTF8_3B, 9) + ".txt"),
229                 Arguments.of(UTF16_CODE_UNITS, 23, repeat(CHAR_UTF8_4B, 10) + ".txt", repeat(CHAR_UTF8_4B, 9) + ".txt"),
230                 // Names without extensions
231                 Arguments.of(UTF16_CODE_UNITS, 1, CHAR_UTF8_1B, CHAR_UTF8_1B),
232                 Arguments.of(UTF16_CODE_UNITS, 1, CHAR_UTF8_2B, CHAR_UTF8_2B),
233                 Arguments.of(UTF16_CODE_UNITS, 1, CHAR_UTF8_3B, CHAR_UTF8_3B),
234                 Arguments.of(UTF16_CODE_UNITS, 2, CHAR_UTF8_4B, CHAR_UTF8_4B),
235                 Arguments.of(UTF16_CODE_UNITS, 9, repeat(CHAR_UTF8_1B, 10), repeat(CHAR_UTF8_1B, 9)),
236                 Arguments.of(UTF16_CODE_UNITS, 9, repeat(CHAR_UTF8_2B, 10), repeat(CHAR_UTF8_2B, 9)),
237                 Arguments.of(UTF16_CODE_UNITS, 9, repeat(CHAR_UTF8_3B, 10), repeat(CHAR_UTF8_3B, 9)),
238                 Arguments.of(UTF16_CODE_UNITS, 19, repeat(CHAR_UTF8_4B, 10), repeat(CHAR_UTF8_4B, 9)),
239                 // Grapheme cluster
240                 Arguments.of(UTF16_CODE_UNITS, 31, CHAR_UTF8_69B, CHAR_UTF8_69B),
241                 // Will not cut 2 or 7 UTF-16 code units of the grapheme cluster
242                 Arguments.of(UTF16_CODE_UNITS, 31 + 2, repeat(CHAR_UTF8_69B, 2), CHAR_UTF8_69B + woman),
243                 Arguments.of(UTF16_CODE_UNITS, 31 + 7, repeat(CHAR_UTF8_69B, 2), CHAR_UTF8_69B + redHeadWoman));
244     }
245 
246     static Stream<Arguments> testNameLengthStrategyTruncate_Throws() {
247         final Stream<Arguments> common = Stream.of(
248                 // Encoding issues
249                 Arguments.of(BYTES, 10, "cafΓ©", US_ASCII, "US-ASCII"),
250                 Arguments.of(UTF16_CODE_UNITS, 10, "\uD800.txt", UTF_8, "UTF-16"),
251                 Arguments.of(UTF16_CODE_UNITS, 10, "\uDC00.txt", UTF_8, "UTF-16"),
252                 // Extension too long
253                 Arguments.of(BYTES, 4, "a.txt", UTF_8, "extension"),
254                 Arguments.of(UTF16_CODE_UNITS, 4, "a.txt", UTF_8, "extension"),
255                 // Limit too small
256                 Arguments.of(BYTES, 3, CHAR_UTF8_4B, UTF_8, "truncated to 1 character"),
257                 Arguments.of(UTF16_CODE_UNITS, 1, CHAR_UTF8_4B, UTF_8, "truncated to 1 character"));
258         return SystemUtils.isJavaVersionAtMost(JavaVersion.JAVA_19)
259                 ? common
260                 : Stream.concat(
261                         common,
262                         // In JDK 20+ the grapheme cluster CHAR_UTF8_69B is treated as a single character,
263                         // so cannot be truncated to 2 or 7 code units
264                         Stream.of(
265                                 Arguments.of(BYTES, 68, CHAR_UTF8_69B, UTF_8, "truncated to 29 characters"),
266                                 Arguments.of(UTF16_CODE_UNITS, 30, CHAR_UTF8_69B, UTF_8, "truncated to 30 characters")));
267     }
268 
269     private String parseXmlRootValue(final Path xmlPath, final Charset charset) throws SAXException, IOException, ParserConfigurationException {
270         final DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
271         try (BufferedReader reader = Files.newBufferedReader(xmlPath, charset)) {
272             final Document document = builder.parse(new InputSource(reader));
273             return document.getDocumentElement().getTextContent();
274         }
275     }
276 
277     private String parseXmlRootValue(final String xmlString) throws SAXException, IOException, ParserConfigurationException {
278         final DocumentBuilder builder = DocumentBuilderFactory.newInstance().newDocumentBuilder();
279         final Document document = builder.parse(new InputSource(new StringReader(xmlString)));
280         return document.getDocumentElement().getTextContent();
281     }
282 
283     @Test
284     void testGetBlockSize() {
285         assertTrue(FileSystem.getCurrent().getBlockSize() >= 0);
286     }
287 
288     @Test
289     void testGetCurrent() {
290         if (SystemUtils.IS_OS_WINDOWS) {
291             assertEquals(FileSystem.WINDOWS, FileSystem.getCurrent());
292         }
293         if (SystemUtils.IS_OS_LINUX) {
294             assertEquals(FileSystem.LINUX, FileSystem.getCurrent());
295         }
296         if (SystemUtils.IS_OS_MAC_OSX) {
297             assertEquals(FileSystem.MAC_OSX, FileSystem.getCurrent());
298         }
299     }
300 
301     @Test
302     void testGetIllegalFileNameChars() {
303         final FileSystem current = FileSystem.getCurrent();
304         assertNotSame(current.getIllegalFileNameChars(), current.getIllegalFileNameChars());
305     }
306 
307     @Test
308     void testGetNameSeparator() {
309         final FileSystem current = FileSystem.getCurrent();
310         assertEquals(SystemProperties.getFileSeparator(), Character.toString(current.getNameSeparator()));
311     }
312 
313     @ParameterizedTest
314     @EnumSource(FileSystem.class)
315     void testIsLegalName(final FileSystem fs) {
316         assertFalse(fs.isLegalFileName(""), fs.name()); // Empty is always illegal
317         assertFalse(fs.isLegalFileName(null), fs.name()); // null is always illegal
318         assertFalse(fs.isLegalFileName("\0"), fs.name()); // Assume NUL is always illegal
319         assertTrue(fs.isLegalFileName("0"), fs.name()); // Assume simple name always legal
320         for (final String candidate : fs.getReservedFileNames()) {
321             // Reserved file names are not legal
322             assertFalse(fs.isLegalFileName(candidate), candidate);
323         }
324     }
325 
326     @Test
327     void testIsLegalName_Encoding() {
328         assertFalse(FileSystem.GENERIC.isLegalFileName(FILE_NAME_255_BYTES_UTF8_3B, US_ASCII), "US-ASCII cannot represent all chars");
329         assertTrue(FileSystem.GENERIC.isLegalFileName(FILE_NAME_255_BYTES_UTF8_3B, UTF_8), "UTF-8 can represent all chars");
330     }
331 
332     @ParameterizedTest(name = "{index}: {0} with charset {2}")
333     @MethodSource
334     void testIsLegalName_Length(final FileSystem fs, final String nameAtLimit, final Charset charset) {
335         assertTrue(fs.isLegalFileName(nameAtLimit, charset), fs.name() + " length at limit");
336         final String nameOverLimit = nameAtLimit + "a";
337         assertFalse(fs.isLegalFileName(nameOverLimit, charset), fs.name() + " length over limit");
338     }
339 
340     @Test
341     void testIsReservedFileName() {
342         for (final FileSystem fs : FileSystem.values()) {
343             for (final String candidate : fs.getReservedFileNames()) {
344                 assertTrue(fs.isReservedFileName(candidate));
345             }
346         }
347     }
348 
349     @Test
350     void testIsReservedFileNameOnWindows() {
351         final FileSystem fs = FileSystem.WINDOWS;
352         for (final String candidate : fs.getReservedFileNames()) {
353             // System.out.printf("Reserved %s exists: %s%n", candidate, Files.exists(Paths.get(candidate)));
354             assertTrue(fs.isReservedFileName(candidate));
355             assertTrue(fs.isReservedFileName(candidate + ".txt"), candidate);
356         }
357 
358 // This can hang when trying to create files for some reserved names, but it is interesting to keep
359 //
360 //        for (final String candidate : fs.getReservedFileNames()) {
361 //            System.out.printf("Testing %s%n", candidate);
362 //            assertTrue(fs.isReservedFileName(candidate));
363 //            final Path path = Paths.get(candidate);
364 //            final boolean exists = Files.exists(path);
365 //            try {
366 //                PathUtils.writeString(path, "Hello World!", StandardCharsets.UTF_8);
367 //            } catch (IOException ignored) {
368 //                // Asking to create a reserved file either:
369 //                // - Throws an exception, for example "AUX"
370 //                // - Is a NOOP, for example "COM3"
371 //            }
372 //            assertEquals(exists, Files.exists(path), path.toString());
373 //        }
374     }
375 
376     @Test
377     void testMaxNameLength_MatchesRealSystem(@TempDir final Path tempDir) {
378         final FileSystem fs = FileSystem.getCurrent();
379         final String[] validNames;
380         switch (fs) {
381         case MAC_OSX:
382         case LINUX:
383             // Names with 255 UTF-8 bytes are legal
384             // @formatter:off
385                 validNames = new String[] {
386                     FILE_NAME_255_BYTES_UTF8_1B,
387                     FILE_NAME_255_BYTES_UTF8_2B,
388                     FILE_NAME_255_BYTES_UTF8_3B,
389                     FILE_NAME_255_BYTES_UTF8_4B
390                 };
391                 // @formatter:on
392             break;
393         case WINDOWS:
394             // Names with 255 UTF-16 code units are legal
395             // @formatter:off
396                 validNames = new String[] {
397                     FILE_NAME_255_CHARS_UTF8_1B,
398                     FILE_NAME_255_CHARS_UTF8_2B,
399                     FILE_NAME_255_CHARS_UTF8_3B,
400                     FILE_NAME_255_CHARS_UTF8_4B
401                 };
402                 // @formatter:on
403             break;
404         default:
405             throw new IllegalStateException("Unexpected value: " + fs);
406         }
407         int failures = 0;
408         for (final String fileName : validNames) {
409             // 1) OS should accept names at the documented limit.
410             assertDoesNotThrow(() -> createAndDelete(tempDir, fileName), "OS should accept max-length name: " + fileName);
411             // 2) Library should consider them legal.
412             assertTrue(fs.isLegalFileName(fileName, UTF_8), "Commons IO should accept max-length name: " + fileName);
413             // 3) For β€œone over” the limit: Commons IO must reject; OS may or may not enforce strictly.
414             final String tooLongName = fileName + "a";
415             // Library contract: must be illegal.
416             assertFalse(fs.isLegalFileName(tooLongName, UTF_8), "Commons IO should reject too-long name: " + tooLongName);
417             // OS behavior: may or may not reject.
418             try {
419                 createAndDelete(tempDir, tooLongName);
420             } catch (final Throwable e) {
421                 failures++;
422                 assertInstanceOf(IOException.class, e, "OS rejects too-long name");
423             }
424         }
425         // On Linux and Windows the API and the filesystem measure name length
426         // in the same unit as the underlying limit (255 bytes on Linux/most POSIX,
427         // 255 UTF-16 code units on Windows).
428         // So all β€œtoo-long” variants should fail.
429         //
430         // macOS is trickier because the API and filesystem limits don’t always match:
431         //
432         // - POSIX API layer (getdirentries/readdir): 1023 bytes per component since macOS 10.5.
433         // https://man.freebsd.org/cgi/man.cgi?query=dir&sektion=5&apropos=0&manpath=macOS+15.6
434         // - HFS+: enforces 255 UTF-16 code units per component.
435         // - APFS: enforces 255 UTF-8 bytes per component.
436         //
437         // Because of this mismatch, depending on which filesystem is mounted,
438         // either all or only FILE_NAME_255BYTES_UTF8_1B + "a" will be rejected.
439         if (SystemUtils.IS_OS_MAC_OSX) {
440             assertTrue(failures >= 1, "Expected at least one too-long name rejected, got " + failures);
441         } else {
442             assertEquals(4, failures, "All too-long names were rejected");
443         }
444     }
445 
446     @ParameterizedTest(name = "{index}: {0} truncates {1} to {2}")
447     @MethodSource
448     void testNameLengthStrategyTruncate_Succeeds(final NameLengthStrategy strategy, final int limit, final String input, final String expected) {
449         final CharSequence out = strategy.truncate(input, limit, UTF_8);
450         assertEquals(expected, out.toString(), strategy.name() + " truncates to limit");
451     }
452 
453     @ParameterizedTest(name = "{index}: {0} truncates {2} with limit {1} throws")
454     @MethodSource
455     void testNameLengthStrategyTruncate_Throws(final NameLengthStrategy strategy, final int limit, final String input, final Charset charset,
456             final String message) {
457         final IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> strategy.truncate(input, limit, charset));
458         final String exMessage = ex.getMessage();
459         assertTrue(exMessage.contains(message), "ex message contains " + message + ": " + exMessage);
460     }
461 
462     @Test
463     void testReplacementWithNUL() {
464         for (final FileSystem fs : FileSystem.values()) {
465             try {
466                 fs.toLegalFileName("Test", '\0'); // Assume NUL is always illegal
467             } catch (final IllegalArgumentException iae) {
468                 assertTrue(iae.getMessage().startsWith("The replacement character '\\0'"), iae.getMessage());
469             }
470         }
471     }
472 
473     @Test
474     void testSorted() {
475         for (final FileSystem fs : FileSystem.values()) {
476             final char[] chars = fs.getIllegalFileNameChars();
477             for (int i = 0; i < chars.length - 1; i++) {
478                 assertTrue(chars[i] < chars[i + 1], fs.name());
479             }
480         }
481     }
482 
483     @Test
484     void testSupportsDriveLetter() {
485         assertTrue(FileSystem.WINDOWS.supportsDriveLetter());
486         assertFalse(FileSystem.GENERIC.supportsDriveLetter());
487         assertFalse(FileSystem.LINUX.supportsDriveLetter());
488         assertFalse(FileSystem.MAC_OSX.supportsDriveLetter());
489     }
490 
491     @Test
492     void testToLegalFileNameWindows() {
493         final FileSystem fs = FileSystem.WINDOWS;
494         final char replacement = '-';
495         for (char i = 0; i < 32; i++) {
496             assertEquals(replacement, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
497         }
498         final char[] illegal = { '<', '>', ':', '"', '/', '\\', '|', '?', '*' };
499         for (char i = 0; i < illegal.length; i++) {
500             assertEquals(replacement, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
501         }
502         for (char i = 'a'; i < 'z'; i++) {
503             assertEquals(i, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
504         }
505         for (char i = 'A'; i < 'Z'; i++) {
506             assertEquals(i, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
507         }
508         for (char i = '0'; i < '9'; i++) {
509             assertEquals(i, fs.toLegalFileName(String.valueOf(i), replacement).charAt(0));
510         }
511         // Null and empty
512         assertThrows(NullPointerException.class, () -> fs.toLegalFileName(null, '_'));
513         assertThrows(IllegalArgumentException.class, () -> fs.toLegalFileName("", '_'));
514         // Illegal replacement
515         assertThrows(IllegalArgumentException.class, () -> fs.toLegalFileName("test", '\0'));
516         assertThrows(IllegalArgumentException.class, () -> fs.toLegalFileName("test", ':'));
517     }
518 
519     @ParameterizedTest
520     @EnumSource(FileSystem.class)
521     void testXmlRoundtrip(final FileSystem fs, @TempDir final Path tempDir) throws Exception {
522         if (SystemUtils.IS_OS_WINDOWS) {
523             // TODO
524             // Window failures with Charset issues on Java 8, 11, and 17 as seen on GH CI, 21 and 24 are OK.
525             assumeTrue(SystemUtils.isJavaVersionAtLeast(JavaVersion.JAVA_21));
526         }
527         final Charset charset = StandardCharsets.UTF_8;
528         assertEquals("a", fs.toLegalFileName("a", '_', charset));
529         assertEquals("abcdefghijklmno", fs.toLegalFileName("abcdefghijklmno", '_', charset));
530         assertEquals("\u4F60\u597D\u55CE", fs.toLegalFileName("\u4F60\u597D\u55CE", '_', charset));
531         assertEquals("\u2713\u2714", fs.toLegalFileName("\u2713\u2714", '_', charset));
532         assertEquals("\uD83D\uDE80\u2728\uD83C\uDF89", fs.toLegalFileName("\uD83D\uDE80\u2728\uD83C\uDF89", '_', charset));
533         assertEquals("\uD83D\uDE03", fs.toLegalFileName("\uD83D\uDE03", '_', charset));
534         assertEquals("\uD83D\uDE03\uD83D\uDE03\uD83D\uDE03\uD83D\uDE03\uD83D\uDE03",
535                 fs.toLegalFileName("\uD83D\uDE03\uD83D\uDE03\uD83D\uDE03\uD83D\uDE03\uD83D\uDE03", '_', charset));
536         for (int i = 1; i <= 10; i++) {
537             final String name1 = fs.toLegalFileName(StringUtils.repeat("🦊", i), '_', charset);
538             assertNotNull(name1);
539             final byte[] name1Bytes = name1.getBytes();
540             final String xmlString1 = toXmlString(name1, charset);
541             final Path path = tempDir.resolve(name1);
542             Files.write(path, xmlString1.getBytes(charset));
543             final String xmlFromPath = parseXmlRootValue(path, charset);
544             assertEquals(name1, xmlFromPath, "i =  " + i);
545             final String name2 = new String(name1Bytes, charset);
546             assertEquals(name1, name2);
547             final String xmlString2 = toXmlString(name2, charset);
548             assertEquals(xmlString1, xmlString2);
549             final String parsedValue = Objects.toString(parseXmlRootValue(xmlString2), "");
550             assertEquals(name1, parsedValue, "i =  " + i);
551             assertEquals(name2, parsedValue, "i =  " + i);
552         }
553 // Fails on some OS' on GH CI
554 //        for (int i = 1; i <= 100; i++) {
555 //            final String name1 = fs.toLegalFileName(fs.getNameLengthStrategy().truncate(
556 //                    "πŸ‘©πŸ»β€πŸ‘¨πŸ»β€πŸ‘¦πŸ»β€πŸ‘¦πŸ»πŸ‘©πŸΌβ€πŸ‘¨πŸΌβ€πŸ‘¦πŸΌβ€πŸ‘¦πŸΌπŸ‘©πŸ½β€πŸ‘¨πŸ½β€πŸ‘¦πŸ½β€πŸ‘¦πŸ½πŸ‘©πŸΎβ€πŸ‘¨πŸΎβ€πŸ‘¦πŸΎβ€πŸ‘¦πŸΎπŸ‘©πŸΏβ€πŸ‘¨πŸΏβ€πŸ‘¦πŸΏβ€πŸ‘¦πŸΏπŸ‘©πŸ»β€πŸ‘¨πŸ»β€πŸ‘¦πŸ»β€πŸ‘¦πŸ»πŸ‘©πŸΌβ€πŸ‘¨πŸΌβ€πŸ‘¦πŸΌβ€πŸ‘¦πŸΌπŸ‘©πŸ½β€πŸ‘¨πŸ½β€πŸ‘¦πŸ½β€πŸ‘¦πŸ½πŸ‘©πŸΎβ€πŸ‘¨πŸΎβ€πŸ‘¦πŸΎβ€πŸ‘¦πŸΎπŸ‘©πŸΏβ€πŸ‘¨πŸΏβ€πŸ‘¦πŸΏβ€πŸ‘¦πŸΏ",
557 //                    // TODO hack 100: truncate blows up when it can't.
558 //                    100 + i, charset), '_', charset);
559 //            assertNotNull(name1);
560 //            final byte[] name1Bytes = name1.getBytes();
561 //            final String xmlString1 = toXmlString(name1, charset);
562 //            final Path path = tempDir.resolve(name1);
563 //            Files.write(path, xmlString1.getBytes(charset));
564 //            final String xmlFromPath = parseXmlRootValue(path, charset);
565 //            assertEquals(name1, xmlFromPath, "i =  " + i);
566 //            final String name2 = new String(name1Bytes, charset);
567 //            assertEquals(name1, name2);
568 //            final String xmlString2 = toXmlString(name2, charset);
569 //            assertEquals(xmlString1, xmlString2);
570 //            final String parsedValue = Objects.toString(parseXmlRootValue(xmlString2), "");
571 //            assertEquals(name1, parsedValue, "i =  " + i);
572 //            assertEquals(name2, parsedValue, "i =  " + i);
573 //        }
574     }
575 
576     private String toXmlString(final String s, final Charset charset) {
577         return String.format("<?xml version=\"1.0\" encoding=\"%s\"?><data>%s</data>", charset.name(), s);
578     }
579 
580 }