Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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);
46 changes: 44 additions & 2 deletions libs/common/src/fileFuncs.cpp
Original file line number Diff line number Diff line change
@@ -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 <boost/filesystem/path.hpp>
#include <algorithm>
#include <array>

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 +44,8 @@ std::string makePortableName(const std::string& fileName)
while(!result.empty() && result.back() == '.')
result.erase(result.end() - 1);
}
if(!result.empty() && isReservedName(result))
Comment thread
MichalLabuda marked this conversation as resolved.
Outdated
result += '_';
assert(result.empty() || bfs::portable_name(result));
return result;
}
Expand Down Expand Up @@ -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)
Comment thread
Flamefire marked this conversation as resolved.
{
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('.')));
}
99 changes: 98 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,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'));
Comment thread
MichalLabuda marked this conversation as resolved.
BOOST_TEST(!isValidFileNameChar('\n'));
BOOST_TEST(!isValidFileNameChar(0x1F));
}

BOOST_AUTO_TEST_CASE(ValidFileName)
{
// Valid names
BOOST_TEST(isValidFileName("my save"));
Comment thread
MichalLabuda marked this conversation as resolved.
Outdated
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"));
Comment thread
MichalLabuda marked this conversation as resolved.
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."));
Comment thread
MichalLabuda marked this conversation as resolved.

// 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"));
Comment thread
MichalLabuda marked this conversation as resolved.
BOOST_TEST(!isValidFileName("NUL.ini"));
BOOST_TEST(!isValidFileName("nul.txt"));
BOOST_TEST(!isValidFileName("com0.txt"));
BOOST_TEST(!isValidFileName("lpt1.bak"));
Comment thread
Flamefire marked this conversation as resolved.
Outdated
// 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"));
Comment thread
MichalLabuda marked this conversation as resolved.
Outdated
}