Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions libs/common/include/s25util/fileFuncs.h
Original file line number Diff line number Diff line change
@@ -1,17 +1,24 @@
// 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

#pragma once

#include <string>

/// 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);
55 changes: 53 additions & 2 deletions libs/common/src/fileFuncs.cpp
Original file line number Diff line number Diff line change
@@ -1,16 +1,32 @@
// 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 <boost/filesystem/path.hpp>
#include <algorithm>
#include <array>
#include <string_view>

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)
Expand All @@ -30,6 +46,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;
}
Expand Down Expand Up @@ -74,3 +92,36 @@ 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.
static constexpr std::u32string_view forbidden = U"<>:\"/\\|?*";
return forbidden.find(c) == std::u32string_view::npos;
}

bool isValidFileName(const std::string& fileName)
Comment thread
Flamefire marked this conversation as resolved.
{
if(fileName.empty() || !s25util::isValidUTF8(fileName))
return false;
if(fileName.size() > 255)
return false;
Comment thread
Flamefire marked this conversation as resolved.
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
// 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('.')));
}
104 changes: 103 additions & 1 deletion tests/testFilefuncs.cpp
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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)
Expand All @@ -44,3 +59,90 @@ 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'));
Comment thread
MichalLabuda marked this conversation as resolved.
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"));
Comment thread
MichalLabuda marked this conversation as resolved.
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."));
Comment thread
MichalLabuda marked this conversation as resolved.
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"));
Comment thread
MichalLabuda marked this conversation as resolved.
BOOST_TEST(!isValidFileName("NUL.ini"));
BOOST_TEST(!isValidFileName("nul.txt"));
BOOST_TEST(!isValidFileName("com0.txt"));

// 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 — verify length is counted in bytes, covering Linux (255 bytes) and Windows (255 UTF-16
// units).
const std::string eacute = "\xC3\xA9";
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
}