Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d55c5fa
Refactor isValidName. Enahnce toValidFileName
cocopaw Oct 5, 2025
4f46011
Improve torrent file path sanitization in TorrentCreator
cocopaw Oct 6, 2025
7b94f38
Enhance toValidPath to match toValidFileName sanitization
cocopaw Oct 6, 2025
5c5d6e5
Refactor to reduce code duplication. Rename isValidName to isValidFil…
cocopaw Oct 13, 2025
425d72e
Enhance "." check and remove toValidPath
cocopaw Oct 14, 2025
5c9ea79
Minor formatting changes
cocopaw Oct 15, 2025
7fbd058
Fix CON_1CON bug
cocopaw Oct 16, 2025
34b4ebb
Remove *Internal from isValidFileName/toValidFileName
cocopaw Oct 16, 2025
cc22407
Reinstate toValidPath
cocopaw Oct 16, 2025
ccc47cd
Update src/base/utils/fs.cpp
cocopaw Oct 17, 2025
2ff23a6
Move Path::isValid() to Utils::Fs::isValidPath
cocopaw Oct 18, 2025
4c6dde4
Rewrite Utils::Fs::isValidPath
cocopaw Oct 18, 2025
ce6dba5
Chocobo1 review requests
cocopaw Oct 18, 2025
2327d43
Rewrite Utils::Fs::toValidPath
cocopaw Oct 19, 2025
bdd88de
Remove Utils::Fs::isValidFileName from TorrentsController::renameFile…
cocopaw Oct 19, 2025
90a1973
Silence unused parameter warning in isDriveLetterPath (non-Windows pl…
cocopaw Oct 20, 2025
c1fc482
Fix Path::isValid() failing on "/"
cocopaw Oct 20, 2025
af9d8ac
Add ":" checking in OSX
cocopaw Oct 20, 2025
4371723
Use 'Q_OS_MACOS' instead of 'Q_OS_OSX' (depricated)
cocopaw Oct 20, 2025
b573f84
Chocobo1 review changes
cocopaw Oct 27, 2025
1eec288
Fix CI error "isReservedCharacter not all control paths return a value"
cocopaw Oct 27, 2025
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
1 change: 1 addition & 0 deletions src/base/bittorrent/sessionimpl.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,7 @@ Path SessionImpl::categoryDownloadPath(const QString &categoryName, const Catego
return (basePath / path);
}


DownloadPathOption SessionImpl::resolveCategoryDownloadPathOption(const QString &categoryName, const std::optional<DownloadPathOption> &option) const
{
if (categoryName.isEmpty())
Expand Down
22 changes: 19 additions & 3 deletions src/base/bittorrent/torrentcreator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
#include "base/exceptions.h"
#include "base/global.h"
#include "base/utils/compare.h"
#include "base/utils/fs.h"
#include "base/utils/io.h"
#include "base/version.h"
#include "lttypecast.h"
Expand Down Expand Up @@ -232,16 +233,31 @@ void TorrentCreator::run()

checkInterruptionRequested();

// Save the generated torrent data to the specified path, sanitizing the filename and falling back to a temporary file if invalid
const auto result = std::invoke([torrentFilePath = m_params.torrentFilePath, entry]() -> nonstd::expected<Path, QString>
{
if (!torrentFilePath.isValid())
Path finalTorrentFilePath = torrentFilePath;

// Extract the filename and parent path
const QString fileName = torrentFilePath.filename();
const Path parentPath = torrentFilePath.parentPath();

// Sanitize the filename using toValidFileName
const QString validFileName = Utils::Fs::toValidFileName(fileName);

// Reconstruct the full path with the sanitized filename
finalTorrentFilePath = parentPath / Path(validFileName);

// Fall back to saving a temporary file if the path is invalid
if (!finalTorrentFilePath.isValid())
return Utils::IO::saveToTempFile(entry);

const nonstd::expected<void, QString> result = Utils::IO::saveToFile(torrentFilePath, entry);
// Attempt to save the file to disk
const nonstd::expected<void, QString> result = Utils::IO::saveToFile(finalTorrentFilePath, entry);
if (!result)
return nonstd::make_unexpected(result.error());

return torrentFilePath;
return finalTorrentFilePath;
});
if (!result)
throw RuntimeError(result.error());
Expand Down
163 changes: 105 additions & 58 deletions src/base/utils/fs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,35 @@

#include "base/path.h"

namespace
{
// Shared set of reserved device names for Windows
#ifdef Q_OS_WIN
const QSet<QString> reservedDeviceNames {
u"CON"_s, u"PRN"_s, u"AUX"_s, u"NUL"_s,
u"COM1"_s, u"COM2"_s, u"COM3"_s, u"COM4"_s,
u"COM5"_s, u"COM6"_s, u"COM7"_s, u"COM8"_s,
u"COM9"_s, u"COM¹"_s, u"COM²"_s, u"COM³"_s,
u"LPT1"_s, u"LPT2"_s, u"LPT3"_s, u"LPT4"_s,
u"LPT5"_s, u"LPT6"_s, u"LPT7"_s, u"LPT8"_s,
u"LPT9"_s, u"LPT¹"_s, u"LPT²"_s, u"LPT³"_s};
#endif

// Shared check if a character is reserved (Control, DEL, '/', or Windows-specific)
bool isReservedCharacter(const QChar &c)
{
const ushort unicode = c.unicode();
if ((unicode < 32) || (unicode == 127) || (c == u'/'))
return true;
#ifdef Q_OS_WIN
static const QSet<QChar> reservedChars{u'\\', u'<', u'>', u':', u'"', u'|', u'?', u'*'};
return reservedChars.contains(c);
#else
return false;
#endif
}
}

/**
* This function will first check if there are only system cache files, e.g. `Thumbs.db`,
* `.DS_Store` and/or only temp files that end with '~', e.g. `filename~`.
Expand Down Expand Up @@ -186,19 +215,92 @@ bool Utils::Fs::sameFiles(const Path &path1, const Path &path2)
return true;
}

// Check if a name is valid without sanitizing
bool Utils::Fs::isValidFileName(const QString &name)
{
// Reject empty names or special directory names
if (name.isEmpty() || name == u"."_s || name == u".."_s)
return false;

// Check for reserved characters
if (std::ranges::any_of(name, isReservedCharacter))
return false;

// Check platform-specific length limit and trailing dot in Windows
#ifdef Q_OS_WIN
if (name.length() > 255 || name.endsWith(u'.'))
return false;
#else
if (name.toUtf8().length() > 255)
return false;
#endif

// Check Windows reserved device names
#ifdef Q_OS_WIN
const qsizetype lastDotIndex = name.lastIndexOf(u'.');
const QString baseName = (lastDotIndex == -1) ? name : name.left(lastDotIndex);
if (reservedDeviceNames.contains(baseName.toUpper()))
return false;
#endif

return true;
}

// Sanitize name using pad
QString Utils::Fs::toValidFileName(const QString &name, const QString &pad)
{
const QRegularExpression regex {u"[\\\\/:?\"*<>|]+"_s};
// Handle empty names or special directory names
if (name.isEmpty() || name == u"."_s || name == u".."_s)
return pad;

// Trim leading/trailing whitespace from name
QString validName = name.trimmed();
validName.replace(regex, pad);

// Replace one or more reserved characters with pad
QString newName;
newName.reserve(validName.size());
bool inReservedSequence = false;
std::ranges::for_each(validName, [&](const QChar &c)
{
if (isReservedCharacter(c))
{
if (!inReservedSequence)
{
newName += pad;
inReservedSequence = true;
}
}
else
{
newName += c;
inReservedSequence = false;
}
});
validName = std::move(newName);

// Handle Windows-specific trailing dots
#ifdef Q_OS_WIN
while (validName.endsWith(u'.'))
validName.chop(1);
#endif

// Handle Windows reserved device names
#ifdef Q_OS_WIN
const qsizetype lastDotIndex = validName.lastIndexOf(u'.');
const QString baseName = (lastDotIndex == -1) ? validName : validName.left(lastDotIndex);
if (reservedDeviceNames.contains(baseName.toUpper()))
{
QString suffix = (lastDotIndex == -1) ? QString() : validName.mid(lastDotIndex);
validName = baseName + pad + u"1"_s + suffix;
}
#endif

return validName;
}

Path Utils::Fs::toValidPath(const QString &name, const QString &pad)
{
const QRegularExpression regex {u"[:?\"*<>|]+"_s};
const QRegularExpression regex {u"[?\"*<>|]+"_s};

QString validPathStr = name;
validPathStr.replace(regex, pad);
Expand All @@ -218,60 +320,6 @@ Path Utils::Fs::tempPath()
return path;
}

// Validates a file name, where "file" refers to both files and directories in Windows and Unix-like systems.
// Returns true if the name is valid, false if it contains empty/special names, exceeds platform-specific lengths,
// uses reserved names, or includes forbidden characters.
bool Utils::Fs::isValidName(const QString &name)
{
// Reject empty names or special directory names (".", "..")
if (name.isEmpty() || (name == u"."_s) || (name == u".."_s))
return false;

#ifdef Q_OS_WIN
// Windows restricts file names to 255 characters and prohibits trailing dots
if ((name.length() > 255) || name.endsWith(u'.'))
return false;
#else
// Non-Windows systems limit file name lengths to 255 bytes in UTF-8 encoding
if (name.toUtf8().length() > 255)
return false;
#endif

#ifdef Q_OS_WIN
// Windows reserves certain names for devices, which cannot be used as file names
const QSet reservedNames
{
u"CON"_s, u"PRN"_s, u"AUX"_s, u"NUL"_s,
u"COM1"_s, u"COM2"_s, u"COM3"_s, u"COM4"_s,
u"COM5"_s, u"COM6"_s, u"COM7"_s, u"COM8"_s,
u"COM9"_s, u"COM¹"_s, u"COM²"_s, u"COM³"_s,
u"LPT1"_s, u"LPT2"_s, u"LPT3"_s, u"LPT4"_s,
u"LPT5"_s, u"LPT6"_s, u"LPT7"_s, u"LPT8"_s,
u"LPT9"_s, u"LPT¹"_s, u"LPT²"_s, u"LPT³"_s
};
const QString baseName = name.section(u'.', 0, 0).toUpper();
if (reservedNames.contains(baseName))
return false;
#endif

// Check for control characters, delete character, and forward slash
for (const QChar &c : name)
{
const ushort unicode = c.unicode();
if ((unicode < 32) || (unicode == 127) || (c == u'/'))
return false;
#ifdef Q_OS_WIN
// Windows forbids reserved characters in file names
if ((c == u'\\') || (c == u'<') || (c == u'>') || (c == u':') || (c == u'"') ||
(c == u'|') || (c == u'?') || (c == u'*'))
return false;
#endif
}

// If none of the invalid conditions are met, the name is valid
return true;
}

bool Utils::Fs::isRegularFile(const Path &path)
{
std::error_code ec;
Expand Down Expand Up @@ -397,7 +445,6 @@ nonstd::expected<void, QString> Utils::Fs::moveFileToTrash(const Path &path)
return nonstd::make_unexpected(!errorMessage.isEmpty() ? errorMessage : QCoreApplication::translate("fs", "Unknown error"));
}


bool Utils::Fs::isReadable(const Path &path)
{
return QFileInfo(path.data()).isReadable();
Expand Down
6 changes: 3 additions & 3 deletions src/base/utils/fs.h
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ namespace Utils::Fs
qint64 computePathSize(const Path &path);
qint64 freeDiskSpaceOnPath(const Path &path);

bool isValidName(const QString &name);
bool isValidFileName(const QString &name);
bool isRegularFile(const Path &path);
bool isDir(const Path &path);
bool isReadable(const Path &path);
Expand All @@ -55,8 +55,8 @@ namespace Utils::Fs
QDateTime lastModified(const Path &path);
bool sameFiles(const Path &path1, const Path &path2);

QString toValidFileName(const QString &name, const QString &pad = u" "_s);
Path toValidPath(const QString &name, const QString &pad = u" "_s);
QString toValidFileName(const QString &name, const QString &pad = u"_"_s);
Path toValidPath(const QString &name, const QString &pad = u"_"_s);
Path toAbsolutePath(const Path &path);
Path toCanonicalPath(const Path &path);

Expand Down
2 changes: 1 addition & 1 deletion src/gui/torrentcontentmodel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,7 @@ bool TorrentContentModel::setData(const QModelIndex &index, const QVariant &valu

if (currentName != newName)
{
if (!Utils::Fs::isValidName(newName))
if (!Utils::Fs::isValidFileName(newName))
{
emit renameFailed(tr("The name is invalid: \"%1\"").arg(newName));
return false;
Expand Down
4 changes: 2 additions & 2 deletions src/webui/api/torrentscontroller.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1991,7 +1991,7 @@ void TorrentsController::renameFileAction()
requireParams({u"hash"_s, u"oldPath"_s, u"newPath"_s});

const QString newFileName = QFileInfo(params()[u"newPath"_s]).fileName();
if (!Utils::Fs::isValidName(newFileName))
if (!Utils::Fs::isValidFileName(newFileName))
throw APIError(APIErrorType::Conflict, tr("File name has invalid characters"));

const auto id = BitTorrent::TorrentID::fromString(params()[u"hash"_s]);
Expand Down Expand Up @@ -2019,7 +2019,7 @@ void TorrentsController::renameFolderAction()
requireParams({u"hash"_s, u"oldPath"_s, u"newPath"_s});

const QString newFolderName = QFileInfo(params()[u"newPath"_s]).fileName();
if (!Utils::Fs::isValidName(newFolderName))
if (!Utils::Fs::isValidFileName(newFolderName))
throw APIError(APIErrorType::Conflict, tr("Folder name has invalid characters"));

const auto id = BitTorrent::TorrentID::fromString(params()[u"hash"_s]);
Expand Down
Loading