From dd36255cae3e370b77b062da8f93b6dc65928c5d Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Sat, 27 Jun 2026 23:52:52 +0200 Subject: [PATCH 1/9] Add reserved name handling to makePortableName and add user filename validation --- libs/common/include/s25util/fileFuncs.h | 21 ++++-- libs/common/src/fileFuncs.cpp | 46 +++++++++++- tests/testFilefuncs.cpp | 99 ++++++++++++++++++++++++- 3 files changed, 156 insertions(+), 10 deletions(-) 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..8195221 100644 --- a/libs/common/src/fileFuncs.cpp +++ b/libs/common/src/fileFuncs.cpp @@ -1,16 +1,30 @@ -// 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 +#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 +44,8 @@ std::string makePortableName(const std::string& fileName) while(!result.empty() && result.back() == '.') result.erase(result.end() - 1); } + if(!result.empty() && isReservedName(result)) + result += '_'; assert(result.empty() || bfs::portable_name(result)); return result; } @@ -74,3 +90,29 @@ 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; + if(fileName.front() == '.' || fileName.back() == '.') + 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(fileName.back() == ' ') + 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..cf482bc 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,85 @@ 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('[')); + BOOST_TEST(isValidFileNameChar(']')); + BOOST_TEST(isValidFileNameChar(U'\u00E9')); // U+00E9 e with acute + + // 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) +{ + // Valid names + BOOST_TEST(isValidFileName("my save")); + BOOST_TEST(isValidFileName("Brick economy test")); + BOOST_TEST(isValidFileName("DevMap (Auto-Save)")); + BOOST_TEST(isValidFileName("save_01")); + BOOST_TEST(isValidFileName("abc")); + + // Empty + BOOST_TEST(!isValidFileName("")); + + // Reserved names (case-insensitive) + BOOST_TEST(!isValidFileName("con")); + BOOST_TEST(!isValidFileName("CON")); + BOOST_TEST(!isValidFileName("nul")); + BOOST_TEST(!isValidFileName("NUL")); + BOOST_TEST(!isValidFileName("com1")); + BOOST_TEST(!isValidFileName("COM9")); + BOOST_TEST(!isValidFileName("com0")); + BOOST_TEST(!isValidFileName("lpt0")); + BOOST_TEST(!isValidFileName("lpt9")); + BOOST_TEST(!isValidFileName("prn")); + BOOST_TEST(!isValidFileName("aux")); + + // Non-reserved names that look similar + BOOST_TEST(isValidFileName("null")); + BOOST_TEST(isValidFileName("console")); + BOOST_TEST(isValidFileName("com10")); + BOOST_TEST(isValidFileName("lpt10")); + + // Leading/trailing dots + BOOST_TEST(!isValidFileName(".hidden")); + BOOST_TEST(!isValidFileName("trail.")); + + // Trailing space (Windows silently strips it, causing name mismatch) + BOOST_TEST(!isValidFileName("trail ")); + + // Reserved base names with extensions are also rejected (Windows 7 compatibility) + BOOST_TEST(!isValidFileName("nul.ini")); + BOOST_TEST(!isValidFileName("NUL.ini")); + BOOST_TEST(!isValidFileName("nul.txt")); + BOOST_TEST(!isValidFileName("com0.txt")); + BOOST_TEST(!isValidFileName("lpt1.bak")); + // Non-reserved names with extensions or dots in the middle are fine + BOOST_TEST(isValidFileName("null.ini")); + BOOST_TEST(isValidFileName("my.save")); + BOOST_TEST(isValidFileName("my save")); +} \ No newline at end of file From 6383cef4de5040eb594970a6faaa4074cf2dfc37 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:23:35 +0200 Subject: [PATCH 2/9] Use isValidFileNameChar in isValidFileName and trim tests to one per category --- libs/common/src/fileFuncs.cpp | 5 +++ tests/testFilefuncs.cpp | 59 ++++++----------------------------- 2 files changed, 14 insertions(+), 50 deletions(-) diff --git a/libs/common/src/fileFuncs.cpp b/libs/common/src/fileFuncs.cpp index 8195221..0c67dbc 100644 --- a/libs/common/src/fileFuncs.cpp +++ b/libs/common/src/fileFuncs.cpp @@ -113,6 +113,11 @@ bool isValidFileName(const std::string& fileName) // the name the user typed and the file actually created on disk. if(fileName.back() == ' ') return false; + for(char c : fileName) + { + if(!isValidFileNameChar(static_cast(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 cf482bc..59151cd 100644 --- a/tests/testFilefuncs.cpp +++ b/tests/testFilefuncs.cpp @@ -64,80 +64,39 @@ 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('[')); - BOOST_TEST(isValidFileNameChar(']')); - BOOST_TEST(isValidFileNameChar(U'\u00E9')); // U+00E9 e with acute + 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 + // Rejected — control character BOOST_TEST(!isValidFileNameChar('\0')); - BOOST_TEST(!isValidFileNameChar('\n')); - BOOST_TEST(!isValidFileNameChar(0x1F)); } BOOST_AUTO_TEST_CASE(ValidFileName) { - // Valid names BOOST_TEST(isValidFileName("my save")); - BOOST_TEST(isValidFileName("Brick economy test")); - BOOST_TEST(isValidFileName("DevMap (Auto-Save)")); - BOOST_TEST(isValidFileName("save_01")); - BOOST_TEST(isValidFileName("abc")); // Empty BOOST_TEST(!isValidFileName("")); - // Reserved names (case-insensitive) + // Reserved name (case-insensitive) BOOST_TEST(!isValidFileName("con")); BOOST_TEST(!isValidFileName("CON")); - BOOST_TEST(!isValidFileName("nul")); - BOOST_TEST(!isValidFileName("NUL")); - BOOST_TEST(!isValidFileName("com1")); - BOOST_TEST(!isValidFileName("COM9")); - BOOST_TEST(!isValidFileName("com0")); - BOOST_TEST(!isValidFileName("lpt0")); - BOOST_TEST(!isValidFileName("lpt9")); - BOOST_TEST(!isValidFileName("prn")); - BOOST_TEST(!isValidFileName("aux")); - // Non-reserved names that look similar + // Non-reserved look-alike BOOST_TEST(isValidFileName("null")); - BOOST_TEST(isValidFileName("console")); - BOOST_TEST(isValidFileName("com10")); - BOOST_TEST(isValidFileName("lpt10")); // Leading/trailing dots BOOST_TEST(!isValidFileName(".hidden")); BOOST_TEST(!isValidFileName("trail.")); - // Trailing space (Windows silently strips it, causing name mismatch) + // Trailing space BOOST_TEST(!isValidFileName("trail ")); - // Reserved base names with extensions are also rejected (Windows 7 compatibility) + // 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")); - BOOST_TEST(!isValidFileName("lpt1.bak")); - // Non-reserved names with extensions or dots in the middle are fine - BOOST_TEST(isValidFileName("null.ini")); - BOOST_TEST(isValidFileName("my.save")); - BOOST_TEST(isValidFileName("my save")); + + // Invalid character + BOOST_TEST(!isValidFileName("save:game")); } \ No newline at end of file From c1773adb827f5f4cbf4b28a7780c3ffd3cc4c556 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=C5=81abuda?= <29762723+MichalLabuda@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:38:57 +0200 Subject: [PATCH 3/9] Apply suggestions from code review Co-authored-by: Alexander Grund --- libs/common/src/fileFuncs.cpp | 2 +- tests/testFilefuncs.cpp | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/libs/common/src/fileFuncs.cpp b/libs/common/src/fileFuncs.cpp index 0c67dbc..eb5c4ee 100644 --- a/libs/common/src/fileFuncs.cpp +++ b/libs/common/src/fileFuncs.cpp @@ -44,7 +44,7 @@ std::string makePortableName(const std::string& fileName) while(!result.empty() && result.back() == '.') result.erase(result.end() - 1); } - if(!result.empty() && isReservedName(result)) + if(isReservedName(result)) result += '_'; assert(result.empty() || bfs::portable_name(result)); return result; diff --git a/tests/testFilefuncs.cpp b/tests/testFilefuncs.cpp index 59151cd..6308bd3 100644 --- a/tests/testFilefuncs.cpp +++ b/tests/testFilefuncs.cpp @@ -90,6 +90,8 @@ BOOST_AUTO_TEST_CASE(ValidFileName) // Leading/trailing dots BOOST_TEST(!isValidFileName(".hidden")); BOOST_TEST(!isValidFileName("trail.")); + BOOST_TEST(!isValidFileName(".")); + BOOST_TEST(!isValidFileName("..")); // Trailing space BOOST_TEST(!isValidFileName("trail ")); From 2fc22bde65dcf048ca725f345f75552c84799fb2 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:48:20 +0200 Subject: [PATCH 4/9] Add missing test case --- tests/testFilefuncs.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/testFilefuncs.cpp b/tests/testFilefuncs.cpp index 59151cd..6d2923f 100644 --- a/tests/testFilefuncs.cpp +++ b/tests/testFilefuncs.cpp @@ -86,6 +86,7 @@ BOOST_AUTO_TEST_CASE(ValidFileName) // Non-reserved look-alike BOOST_TEST(isValidFileName("null")); + BOOST_TEST(isValidFileName("null.ini")); // Leading/trailing dots BOOST_TEST(!isValidFileName(".hidden")); From 015b15e5a17d6b0a5df5908e49a87d97d552e861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20=C5=81abuda?= <29762723+MichalLabuda@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:31:14 +0200 Subject: [PATCH 5/9] Test cases restored according to suggestions from code review Co-authored-by: Alexander Grund --- tests/testFilefuncs.cpp | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/testFilefuncs.cpp b/tests/testFilefuncs.cpp index 2dea81b..ca5d47a 100644 --- a/tests/testFilefuncs.cpp +++ b/tests/testFilefuncs.cpp @@ -64,18 +64,39 @@ 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(':')); - // Rejected — control character + 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("my save")); + 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("")); @@ -83,6 +104,9 @@ BOOST_AUTO_TEST_CASE(ValidFileName) // 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")); @@ -99,6 +123,9 @@ BOOST_AUTO_TEST_CASE(ValidFileName) // 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")); From 70d869c286565835c847ffe45da4e54284e0dfb8 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:42:42 +0200 Subject: [PATCH 6/9] Add length check (255 code points) to isValidFileName --- libs/common/src/fileFuncs.cpp | 3 +++ tests/testFilefuncs.cpp | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/libs/common/src/fileFuncs.cpp b/libs/common/src/fileFuncs.cpp index eb5c4ee..d14df0e 100644 --- a/libs/common/src/fileFuncs.cpp +++ b/libs/common/src/fileFuncs.cpp @@ -4,6 +4,7 @@ #include "fileFuncs.h" #include "s25util/strAlgos.h" +#include "s25util/utf8.h" #include #include #include @@ -107,6 +108,8 @@ bool isValidFileName(const std::string& fileName) { if(fileName.empty()) return false; + if(s25util::utf8to32(fileName).size() > 255) + return false; if(fileName.front() == '.' || fileName.back() == '.') return false; // Windows silently strips trailing spaces, which would create a mismatch between diff --git a/tests/testFilefuncs.cpp b/tests/testFilefuncs.cpp index ca5d47a..5aa5cce 100644 --- a/tests/testFilefuncs.cpp +++ b/tests/testFilefuncs.cpp @@ -129,4 +129,16 @@ BOOST_AUTO_TEST_CASE(ValidFileName) // 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 From 40b58ea143c97d30468a75cb8692330fdff76b25 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Mon, 29 Jun 2026 14:35:34 +0200 Subject: [PATCH 7/9] Fix isValidFileName to iterate UTF-32 code points instead of raw UTF-8 bytes --- libs/common/src/fileFuncs.cpp | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/libs/common/src/fileFuncs.cpp b/libs/common/src/fileFuncs.cpp index d14df0e..c367657 100644 --- a/libs/common/src/fileFuncs.cpp +++ b/libs/common/src/fileFuncs.cpp @@ -108,17 +108,18 @@ bool isValidFileName(const std::string& fileName) { if(fileName.empty()) return false; - if(s25util::utf8to32(fileName).size() > 255) + const auto asU32 = s25util::utf8to32(fileName); + if(asU32.size() > 255) return false; - if(fileName.front() == '.' || fileName.back() == '.') + 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(fileName.back() == ' ') + if(asU32.back() == U' ') return false; - for(char c : fileName) + for(char32_t c : asU32) { - if(!isValidFileNameChar(static_cast(c))) + 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. From f19a93c043aea9ad79515ebc19c22bfbf63b9640 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:10:17 +0200 Subject: [PATCH 8/9] Replace forbidden-char || chain in isValidFileNameChar with constexpr string_view find --- libs/common/src/fileFuncs.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/common/src/fileFuncs.cpp b/libs/common/src/fileFuncs.cpp index c367657..7290462 100644 --- a/libs/common/src/fileFuncs.cpp +++ b/libs/common/src/fileFuncs.cpp @@ -8,6 +8,7 @@ #include #include #include +#include namespace bfs = boost::filesystem; @@ -99,9 +100,8 @@ bool isValidFileNameChar(char32_t c) 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; + static constexpr std::u32string_view forbidden = U"<>:\"/\\|?*"; + return forbidden.find(c) == std::u32string_view::npos; } bool isValidFileName(const std::string& fileName) From c602e6f6c42f2a506bd82e10fa1172241c9fa540 Mon Sep 17 00:00:00 2001 From: MichalLabuda <29762723+MichalLabuda@users.noreply.github.com> Date: Mon, 29 Jun 2026 19:12:21 +0200 Subject: [PATCH 9/9] Reject invalid UTF-8 and tighten length check to 255 bytes to cover Linux ext4 and Windows NTFS --- libs/common/src/fileFuncs.cpp | 6 +++--- tests/testFilefuncs.cpp | 16 ++++++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/libs/common/src/fileFuncs.cpp b/libs/common/src/fileFuncs.cpp index 7290462..bce9da3 100644 --- a/libs/common/src/fileFuncs.cpp +++ b/libs/common/src/fileFuncs.cpp @@ -106,11 +106,11 @@ bool isValidFileNameChar(char32_t c) bool isValidFileName(const std::string& fileName) { - if(fileName.empty()) + if(fileName.empty() || !s25util::isValidUTF8(fileName)) return false; - const auto asU32 = s25util::utf8to32(fileName); - if(asU32.size() > 255) + if(fileName.size() > 255) return false; + const auto asU32 = s25util::utf8to32(fileName); if(asU32.front() == U'.' || asU32.back() == U'.') return false; // Windows silently strips trailing spaces, which would create a mismatch between diff --git a/tests/testFilefuncs.cpp b/tests/testFilefuncs.cpp index 5aa5cce..6a10158 100644 --- a/tests/testFilefuncs.cpp +++ b/tests/testFilefuncs.cpp @@ -130,15 +130,19 @@ BOOST_AUTO_TEST_CASE(ValidFileName) // Invalid character BOOST_TEST(!isValidFileName("save:game")); + // Invalid UTF-8 + BOOST_TEST(!isValidFileName("\x80")); // isolated continuation byte + // 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. + // é (U+00E9): 2 UTF-8 bytes — verify length is counted in bytes, covering Linux (255 bytes) and Windows (255 UTF-16 + // units). 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 + std::string e128; + for(int i = 0; i < 128; ++i) + e128 += eacute; + BOOST_TEST(isValidFileName(e128.substr(0, e128.size() - eacute.size()))); // 127 code points, 254 bytes + BOOST_TEST(!isValidFileName(e128)); // 128 code points, 256 bytes } \ No newline at end of file