diff --git a/libs/common/include/s25util/fileFuncs.h b/libs/common/include/s25util/fileFuncs.h index ebb2383..6100ce9 100644 --- a/libs/common/include/s25util/fileFuncs.h +++ b/libs/common/include/s25util/fileFuncs.h @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -6,12 +6,19 @@ #include -/// Remove all invalid chars of a file or directory name. Result may be empty! -/// --> bfs::portable_name will return true +/// Sanitizes to a portable name. Appends '_' to Windows reserved device names. Result may be empty. +/// --> bfs::portable_name will return true for non-empty results std::string makePortableName(const std::string& fileName); -/// Remove all invalid chars so the name can be used for a file. Result may be empty! -/// --> bfs::portable_file_name will return true +/// Sanitizes to a portable filename. Result may be empty. +/// --> bfs::portable_file_name will return true for non-empty results std::string makePortableFileName(const std::string& fileName); -/// Remove all invalid chars so the name can be used for a directory. Result may be empty! -/// --> bfs::portable_directory_name will return true +/// Sanitizes to a portable directory name. Result may be empty. +/// --> bfs::portable_directory_name will return true for non-empty results std::string makePortableDirName(const std::string& fileName); + +/// Returns true if c is valid in a user-provided filename. +/// Rejects control characters and chars forbidden on Windows (< > : " / \ | ? *). +bool isValidFileNameChar(char32_t c); +/// Returns true if fileName is a valid user-provided filename. +/// Rejects reserved device names, empty names, leading/trailing dots, and trailing spaces. +bool isValidFileName(const std::string& fileName); diff --git a/libs/common/src/fileFuncs.cpp b/libs/common/src/fileFuncs.cpp index 0820713..c367657 100644 --- a/libs/common/src/fileFuncs.cpp +++ b/libs/common/src/fileFuncs.cpp @@ -1,16 +1,31 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later #include "fileFuncs.h" +#include "s25util/strAlgos.h" +#include "s25util/utf8.h" #include +#include +#include namespace bfs = boost::filesystem; +// Windows reserved device names +static constexpr std::array reservedNames{"con", "prn", "aux", "nul", "com0", "com1", "com2", "com3", + "com4", "com5", "com6", "com7", "com8", "com9", "lpt0", "lpt1", + "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"}; + +static bool isReservedName(const std::string& name) +{ + const std::string lower = s25util::toLower(name); + return std::find(reservedNames.begin(), reservedNames.end(), lower) != reservedNames.end(); +} + std::string makePortableName(const std::string& fileName) { if(fileName.empty() || bfs::portable_name(fileName)) - return fileName; + return isReservedName(fileName) ? fileName + '_' : fileName; std::string result; result.reserve(fileName.size()); for(char c : fileName) @@ -30,6 +45,8 @@ std::string makePortableName(const std::string& fileName) while(!result.empty() && result.back() == '.') result.erase(result.end() - 1); } + if(isReservedName(result)) + result += '_'; assert(result.empty() || bfs::portable_name(result)); return result; } @@ -74,3 +91,37 @@ std::string makePortableDirName(const std::string& fileName) assert(result.empty() || bfs::portable_directory_name(result)); return result; } + +bool isValidFileNameChar(char32_t c) +{ + // Reject control characters + if(c <= 0x1F || c == 0x7F) + return false; + // Reject characters forbidden on Windows (the most restrictive platform), + // which covers all restrictions on Linux, macOS, and Android as well. + if(c == '<' || c == '>' || c == ':' || c == '"' || c == '/' || c == '\\' || c == '|' || c == '?' || c == '*') + return false; + return true; +} + +bool isValidFileName(const std::string& fileName) +{ + if(fileName.empty()) + return false; + const auto asU32 = s25util::utf8to32(fileName); + if(asU32.size() > 255) + return false; + if(asU32.front() == U'.' || asU32.back() == U'.') + return false; + // Windows silently strips trailing spaces, which would create a mismatch between + // the name the user typed and the file actually created on disk. + if(asU32.back() == U' ') + return false; + for(char32_t c : asU32) + { + if(!isValidFileNameChar(c)) + return false; + } + // On Windows 7 and earlier the device name is the part before the first dot — "nul.ini" is NUL thus forbidden. + return !isReservedName(fileName.substr(0, fileName.find('.'))); +} diff --git a/tests/testFilefuncs.cpp b/tests/testFilefuncs.cpp index 2e40bc3..5aa5cce 100644 --- a/tests/testFilefuncs.cpp +++ b/tests/testFilefuncs.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -20,6 +20,21 @@ BOOST_AUTO_TEST_CASE(PortableName) BOOST_TEST(makePortableName("~abc") == "_abc"); BOOST_TEST(makePortableName("abc ") == "abc_"); BOOST_TEST(makePortableName("abc.") == "abc"); + + // Reserved names get _ appended + BOOST_TEST(makePortableName("con") == "con_"); + BOOST_TEST(makePortableName("NUL") == "NUL_"); + BOOST_TEST(makePortableName("com1") == "com1_"); + BOOST_TEST(makePortableName("lpt9") == "lpt9_"); + BOOST_TEST(makePortableName("com0") == "com0_"); + BOOST_TEST(makePortableName("lpt0") == "lpt0_"); + BOOST_TEST(makePortableName("prn") == "prn_"); + BOOST_TEST(makePortableName("aux") == "aux_"); + // Non-reserved names are unchanged + BOOST_TEST(makePortableName("null") == "null"); + BOOST_TEST(makePortableName("console") == "console"); + BOOST_TEST(makePortableName("com10") == "com10"); + BOOST_TEST(makePortableName("lpt10") == "lpt10"); } BOOST_AUTO_TEST_CASE(PortableFileName) @@ -44,3 +59,86 @@ BOOST_AUTO_TEST_CASE(PortableDirName) BOOST_TEST(makePortableDirName("file.extLONG") == "fileextLONG"); BOOST_TEST(makePortableDirName("file....") == "file"); } + +BOOST_AUTO_TEST_CASE(ValidFileNameChar) +{ + // Allowed + BOOST_TEST(isValidFileNameChar('a')); + BOOST_TEST(isValidFileNameChar('Z')); + BOOST_TEST(isValidFileNameChar('5')); + BOOST_TEST(isValidFileNameChar(' ')); + BOOST_TEST(isValidFileNameChar('.')); + BOOST_TEST(isValidFileNameChar('_')); + BOOST_TEST(isValidFileNameChar('-')); + BOOST_TEST(isValidFileNameChar('(')); + BOOST_TEST(isValidFileNameChar(']')); + BOOST_TEST(isValidFileNameChar(U'\u00E9')); // non-ASCII Unicode + + // Rejected — Windows-forbidden + BOOST_TEST(!isValidFileNameChar('<')); + BOOST_TEST(!isValidFileNameChar('>')); + BOOST_TEST(!isValidFileNameChar(':')); + BOOST_TEST(!isValidFileNameChar('"')); + BOOST_TEST(!isValidFileNameChar('/')); + BOOST_TEST(!isValidFileNameChar('\\')); + BOOST_TEST(!isValidFileNameChar('|')); + BOOST_TEST(!isValidFileNameChar('?')); + BOOST_TEST(!isValidFileNameChar('*')); + // Rejected — control characters + BOOST_TEST(!isValidFileNameChar('\0')); + BOOST_TEST(!isValidFileNameChar('\n')); + BOOST_TEST(!isValidFileNameChar(0x1F)); +} + +BOOST_AUTO_TEST_CASE(ValidFileName) +{ + BOOST_TEST(isValidFileName("abc")); + BOOST_TEST(isValidFileName("Brick economy test")); + BOOST_TEST(isValidFileName("DevMap (Auto-Save)")); + BOOST_TEST(isValidFileName("save_01")); + BOOST_TEST(isValidFileName("my save.sav")); + + // Empty + BOOST_TEST(!isValidFileName("")); + + // Reserved name (case-insensitive) + BOOST_TEST(!isValidFileName("con")); + BOOST_TEST(!isValidFileName("CON")); + BOOST_TEST(!isValidFileName("nul")); + BOOST_TEST(!isValidFileName("NUL")); + BOOST_TEST(!isValidFileName("Lpt0")); + + // Non-reserved look-alike + BOOST_TEST(isValidFileName("null")); + BOOST_TEST(isValidFileName("null.ini")); + + // Leading/trailing dots + BOOST_TEST(!isValidFileName(".hidden")); + BOOST_TEST(!isValidFileName("trail.")); + BOOST_TEST(!isValidFileName(".")); + BOOST_TEST(!isValidFileName("..")); + + // Trailing space + BOOST_TEST(!isValidFileName("trail ")); + + // Reserved base name with extension (Windows 7 compat) + BOOST_TEST(!isValidFileName("nul.ini")); + BOOST_TEST(!isValidFileName("NUL.ini")); + BOOST_TEST(!isValidFileName("nul.txt")); + BOOST_TEST(!isValidFileName("com0.txt")); + + // Invalid character + BOOST_TEST(!isValidFileName("save:game")); + + // Length limit (255 Unicode code points) + BOOST_TEST(isValidFileName(std::string(255, 'a'))); + BOOST_TEST(!isValidFileName(std::string(256, 'a'))); + + // é (U+00E9): 2 UTF-8 bytes but 1 code point - verify length is counted in code points, not bytes. + const std::string eacute = "\xC3\xA9"; + std::string e256; + for(int i = 0; i < 256; ++i) + e256 += eacute; + BOOST_TEST(isValidFileName(e256.substr(0, e256.size() - eacute.size()))); // 255 code points, 510 bytes + BOOST_TEST(!isValidFileName(e256)); // 256 code points, 512 bytes +} \ No newline at end of file