mirror of
https://github.com/qbittorrent/qBittorrent.git
synced 2025-12-23 16:58:06 -06:00
Don't read unlimited data from files
It now guards against reading infinite files such as `/dev/zero`. And most readings are bound with a (lax) limit. As a side effect, more checking are done when reading a file and overall the reading procedure is more robust. PR #19095.
This commit is contained in:
@@ -36,6 +36,7 @@
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QRegularExpression>
|
||||
#include <QThread>
|
||||
|
||||
@@ -133,17 +134,19 @@ BitTorrent::LoadResumeDataResult BitTorrent::BencodeResumeDataStorage::load(cons
|
||||
const Path fastresumePath = path() / Path(idString + u".fastresume");
|
||||
const Path torrentFilePath = path() / Path(idString + u".torrent");
|
||||
|
||||
QFile resumeDataFile {fastresumePath.data()};
|
||||
if (!resumeDataFile.open(QIODevice::ReadOnly))
|
||||
return nonstd::make_unexpected(tr("Cannot read file %1: %2").arg(fastresumePath.toString(), resumeDataFile.errorString()));
|
||||
const auto resumeDataReadResult = Utils::IO::readFile(fastresumePath, MAX_TORRENT_SIZE);
|
||||
if (!resumeDataReadResult)
|
||||
return nonstd::make_unexpected(resumeDataReadResult.error().message);
|
||||
|
||||
QFile metadataFile {torrentFilePath.data()};
|
||||
if (metadataFile.exists() && !metadataFile.open(QIODevice::ReadOnly))
|
||||
return nonstd::make_unexpected(tr("Cannot read file %1: %2").arg(torrentFilePath.toString(), metadataFile.errorString()));
|
||||
|
||||
const QByteArray data = resumeDataFile.readAll();
|
||||
const QByteArray metadata = (metadataFile.isOpen() ? metadataFile.readAll() : "");
|
||||
const auto metadataReadResult = Utils::IO::readFile(torrentFilePath, MAX_TORRENT_SIZE);
|
||||
if (!metadataReadResult)
|
||||
{
|
||||
if (metadataReadResult.error().status != Utils::IO::ReadError::NotExist)
|
||||
return nonstd::make_unexpected(metadataReadResult.error().message);
|
||||
}
|
||||
|
||||
const QByteArray data = resumeDataReadResult.value();
|
||||
const QByteArray metadata = metadataReadResult.value_or(QByteArray());
|
||||
return loadTorrentResumeData(data, metadata);
|
||||
}
|
||||
|
||||
@@ -161,6 +164,8 @@ void BitTorrent::BencodeResumeDataStorage::doLoadAll() const
|
||||
|
||||
void BitTorrent::BencodeResumeDataStorage::loadQueue(const Path &queueFilename)
|
||||
{
|
||||
const int lineMaxLength = 48;
|
||||
|
||||
QFile queueFile {queueFilename.data()};
|
||||
if (!queueFile.exists())
|
||||
return;
|
||||
@@ -175,7 +180,7 @@ void BitTorrent::BencodeResumeDataStorage::loadQueue(const Path &queueFilename)
|
||||
int start = 0;
|
||||
while (true)
|
||||
{
|
||||
const auto line = QString::fromLatin1(queueFile.readLine().trimmed());
|
||||
const auto line = QString::fromLatin1(queueFile.readLine(lineMaxLength).trimmed());
|
||||
if (line.isEmpty())
|
||||
break;
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QMutex>
|
||||
#include <QSet>
|
||||
#include <QSqlDatabase>
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QHostAddress>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
@@ -5101,8 +5100,8 @@ void SessionImpl::loadCategories()
|
||||
{
|
||||
m_categories.clear();
|
||||
|
||||
QFile confFile {(specialFolderLocation(SpecialFolder::Config) / CATEGORIES_FILE_NAME).data()};
|
||||
if (!confFile.exists())
|
||||
const Path path = specialFolderLocation(SpecialFolder::Config) / CATEGORIES_FILE_NAME;
|
||||
if (!path.exists())
|
||||
{
|
||||
// TODO: Remove the following upgrade code in v4.5
|
||||
// == BEGIN UPGRADE CODE ==
|
||||
@@ -5113,26 +5112,27 @@ void SessionImpl::loadCategories()
|
||||
// return;
|
||||
}
|
||||
|
||||
if (!confFile.open(QFile::ReadOnly))
|
||||
const int fileMaxSize = 1024 * 1024;
|
||||
const auto readResult = Utils::IO::readFile(path, fileMaxSize);
|
||||
if (!readResult)
|
||||
{
|
||||
LogMsg(tr("Failed to load Categories. File: \"%1\". Error: \"%2\"")
|
||||
.arg(confFile.fileName(), confFile.errorString()), Log::CRITICAL);
|
||||
LogMsg(tr("Failed to load Categories. %1").arg(readResult.error().message), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError jsonError;
|
||||
const QJsonDocument jsonDoc = QJsonDocument::fromJson(confFile.readAll(), &jsonError);
|
||||
const QJsonDocument jsonDoc = QJsonDocument::fromJson(readResult.value(), &jsonError);
|
||||
if (jsonError.error != QJsonParseError::NoError)
|
||||
{
|
||||
LogMsg(tr("Failed to parse Categories configuration. File: \"%1\". Error: \"%2\"")
|
||||
.arg(confFile.fileName(), jsonError.errorString()), Log::WARNING);
|
||||
.arg(path.toString(), jsonError.errorString()), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jsonDoc.isObject())
|
||||
{
|
||||
LogMsg(tr("Failed to load Categories configuration. File: \"%1\". Reason: invalid data format")
|
||||
.arg(confFile.fileName()), Log::WARNING);
|
||||
LogMsg(tr("Failed to load Categories configuration. File: \"%1\". Error: \"Invalid data format\"")
|
||||
.arg(path.toString()), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QPointer>
|
||||
#include <QSet>
|
||||
#include <QStringList>
|
||||
|
||||
@@ -103,28 +103,20 @@ nonstd::expected<TorrentInfo, QString> TorrentInfo::load(const QByteArray &data)
|
||||
|
||||
nonstd::expected<TorrentInfo, QString> TorrentInfo::loadFromFile(const Path &path) noexcept
|
||||
{
|
||||
QFile file {path.data()};
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
return nonstd::make_unexpected(file.errorString());
|
||||
|
||||
if (file.size() > MAX_TORRENT_SIZE)
|
||||
return nonstd::make_unexpected(tr("File size exceeds max limit %1").arg(Utils::Misc::friendlyUnit(MAX_TORRENT_SIZE)));
|
||||
|
||||
QByteArray data;
|
||||
try
|
||||
{
|
||||
data = file.readAll();
|
||||
const auto readResult = Utils::IO::readFile(path, MAX_TORRENT_SIZE);
|
||||
if (!readResult)
|
||||
return nonstd::make_unexpected(readResult.error().message);
|
||||
data = readResult.value();
|
||||
}
|
||||
catch (const std::bad_alloc &e)
|
||||
{
|
||||
return nonstd::make_unexpected(tr("Torrent file read error: %1").arg(QString::fromLocal8Bit(e.what())));
|
||||
return nonstd::make_unexpected(tr("Failed to allocate memory when reading file. File: \"%1\". Error: \"%2\"")
|
||||
.arg(path.toString(), QString::fromLocal8Bit(e.what())));
|
||||
}
|
||||
|
||||
if (data.size() != file.size())
|
||||
return nonstd::make_unexpected(tr("Torrent file read error: size mismatch"));
|
||||
|
||||
file.close();
|
||||
|
||||
return load(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@
|
||||
|
||||
#include "feed_serializer.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
@@ -46,23 +45,21 @@ const int ARTICLEDATALIST_TYPEID = qRegisterMetaType<QVector<QVariantHash>>();
|
||||
|
||||
void RSS::Private::FeedSerializer::load(const Path &dataFileName, const QString &url)
|
||||
{
|
||||
QFile file {dataFileName.data()};
|
||||
const int fileMaxSize = 10 * 1024 * 1024;
|
||||
const auto readResult = Utils::IO::readFile(dataFileName, fileMaxSize);
|
||||
if (!readResult)
|
||||
{
|
||||
if (readResult.error().status == Utils::IO::ReadError::NotExist)
|
||||
{
|
||||
emit loadingFinished({});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!file.exists())
|
||||
{
|
||||
emit loadingFinished({});
|
||||
}
|
||||
else if (file.open(QFile::ReadOnly))
|
||||
{
|
||||
emit loadingFinished(loadArticles(file.readAll(), url));
|
||||
file.close();
|
||||
}
|
||||
else
|
||||
{
|
||||
LogMsg(tr("Couldn't read RSS Session data from %1. Error: %2")
|
||||
.arg(dataFileName.toString(), file.errorString())
|
||||
, Log::WARNING);
|
||||
LogMsg(tr("Failed to read RSS session data. %1").arg(readResult.error().message), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
emit loadingFinished(loadArticles(readResult.value(), url));
|
||||
}
|
||||
|
||||
void RSS::Private::FeedSerializer::store(const Path &dataFileName, const QVector<QVariantHash> &articlesData)
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
#include "../logger.h"
|
||||
#include "../profile.h"
|
||||
#include "../utils/fs.h"
|
||||
#include "../utils/io.h"
|
||||
#include "rss_article.h"
|
||||
#include "rss_autodownloadrule.h"
|
||||
#include "rss_feed.h"
|
||||
@@ -493,21 +494,21 @@ void AutoDownloader::processJob(const QSharedPointer<ProcessingJob> &job)
|
||||
|
||||
void AutoDownloader::load()
|
||||
{
|
||||
QFile rulesFile {(m_fileStorage->storageDir() / Path(RULES_FILE_NAME)).data()};
|
||||
const qint64 maxFileSize = 10 * 1024 * 1024;
|
||||
const auto readResult = Utils::IO::readFile((m_fileStorage->storageDir() / Path(RULES_FILE_NAME)), maxFileSize);
|
||||
if (!readResult)
|
||||
{
|
||||
if (readResult.error().status == Utils::IO::ReadError::NotExist)
|
||||
{
|
||||
loadRulesLegacy();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!rulesFile.exists())
|
||||
{
|
||||
loadRulesLegacy();
|
||||
}
|
||||
else if (rulesFile.open(QFile::ReadOnly))
|
||||
{
|
||||
loadRules(rulesFile.readAll());
|
||||
}
|
||||
else
|
||||
{
|
||||
LogMsg(tr("Couldn't read RSS AutoDownloader rules from %1. Error: %2")
|
||||
.arg(rulesFile.fileName(), rulesFile.errorString()), Log::CRITICAL);
|
||||
LogMsg((tr("Failed to read RSS AutoDownloader rules. %1").arg(readResult.error().message)), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
loadRules(readResult.value());
|
||||
}
|
||||
|
||||
void AutoDownloader::loadRules(const QByteArray &data)
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
#include "../profile.h"
|
||||
#include "../settingsstorage.h"
|
||||
#include "../utils/fs.h"
|
||||
#include "../utils/io.h"
|
||||
#include "rss_article.h"
|
||||
#include "rss_feed.h"
|
||||
#include "rss_folder.h"
|
||||
@@ -261,33 +262,35 @@ Item *Session::itemByPath(const QString &path) const
|
||||
|
||||
void Session::load()
|
||||
{
|
||||
QFile itemsFile {(m_confFileStorage->storageDir() / Path(FEEDS_FILE_NAME)).data()};
|
||||
if (!itemsFile.exists())
|
||||
{
|
||||
loadLegacy();
|
||||
return;
|
||||
}
|
||||
const int fileMaxSize = 10 * 1024 * 1024;
|
||||
const Path path = m_confFileStorage->storageDir() / Path(FEEDS_FILE_NAME);
|
||||
|
||||
if (!itemsFile.open(QFile::ReadOnly))
|
||||
const auto readResult = Utils::IO::readFile(path, fileMaxSize);
|
||||
if (!readResult)
|
||||
{
|
||||
LogMsg(tr("Couldn't read RSS session data. File: \"%1\". Error: \"%2\"")
|
||||
.arg(itemsFile.fileName(), itemsFile.errorString()), Log::WARNING);
|
||||
if (readResult.error().status == Utils::IO::ReadError::NotExist)
|
||||
{
|
||||
loadLegacy();
|
||||
return;
|
||||
}
|
||||
|
||||
LogMsg(tr("Failed to read RSS session data. %1").arg(readResult.error().message), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError jsonError;
|
||||
const QJsonDocument jsonDoc = QJsonDocument::fromJson(itemsFile.readAll(), &jsonError);
|
||||
const QJsonDocument jsonDoc = QJsonDocument::fromJson(readResult.value(), &jsonError);
|
||||
if (jsonError.error != QJsonParseError::NoError)
|
||||
{
|
||||
LogMsg(tr("Couldn't parse RSS session data. File: \"%1\". Error: \"%2\"")
|
||||
.arg(itemsFile.fileName(), jsonError.errorString()), Log::WARNING);
|
||||
LogMsg(tr("Failed to parse RSS session data. File: \"%1\". Error: \"%2\"")
|
||||
.arg(path.toString(), jsonError.errorString()), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jsonDoc.isObject())
|
||||
{
|
||||
LogMsg(tr("Couldn't load RSS session data. File: \"%1\". Error: Invalid data format.")
|
||||
.arg(itemsFile.fileName()), Log::WARNING);
|
||||
LogMsg(tr("Failed to load RSS session data. File: \"%1\". Error: \"Invalid data format.\"")
|
||||
.arg(path.toString()), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
#include <QDomDocument>
|
||||
#include <QDomElement>
|
||||
#include <QDomNode>
|
||||
#include <QFile>
|
||||
#include <QPointer>
|
||||
#include <QProcess>
|
||||
#include <QUrl>
|
||||
@@ -517,7 +518,7 @@ void SearchPluginManager::update()
|
||||
nova.start(Utils::ForeignApps::pythonInfo().executableName, params, QIODevice::ReadOnly);
|
||||
nova.waitForFinished();
|
||||
|
||||
const auto capabilities = QString::fromUtf8(nova.readAll());
|
||||
const auto capabilities = QString::fromUtf8(nova.readAllStandardOutput());
|
||||
QDomDocument xmlDoc;
|
||||
if (!xmlDoc.setContent(capabilities))
|
||||
{
|
||||
@@ -629,13 +630,15 @@ Path SearchPluginManager::pluginPath(const QString &name)
|
||||
|
||||
PluginVersion SearchPluginManager::getPluginVersion(const Path &filePath)
|
||||
{
|
||||
const int lineMaxLength = 16;
|
||||
|
||||
QFile pluginFile {filePath.data()};
|
||||
if (!pluginFile.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
return {};
|
||||
|
||||
while (!pluginFile.atEnd())
|
||||
{
|
||||
const auto line = QString::fromUtf8(pluginFile.readLine()).remove(u' ');
|
||||
const auto line = QString::fromUtf8(pluginFile.readLine(lineMaxLength)).remove(u' ');
|
||||
if (!line.startsWith(u"#VERSION:", Qt::CaseInsensitive)) continue;
|
||||
|
||||
const QString versionStr = line.mid(9);
|
||||
|
||||
@@ -177,33 +177,35 @@ void TorrentFilesWatcher::initWorker()
|
||||
|
||||
void TorrentFilesWatcher::load()
|
||||
{
|
||||
QFile confFile {(specialFolderLocation(SpecialFolder::Config) / Path(CONF_FILE_NAME)).data()};
|
||||
if (!confFile.exists())
|
||||
{
|
||||
loadLegacy();
|
||||
return;
|
||||
}
|
||||
const int fileMaxSize = 10 * 1024 * 1024;
|
||||
const Path path = specialFolderLocation(SpecialFolder::Config) / Path(CONF_FILE_NAME);
|
||||
|
||||
if (!confFile.open(QFile::ReadOnly))
|
||||
const auto readResult = Utils::IO::readFile(path, fileMaxSize);
|
||||
if (!readResult)
|
||||
{
|
||||
LogMsg(tr("Couldn't load Watched Folders configuration from %1. Error: %2")
|
||||
.arg(confFile.fileName(), confFile.errorString()), Log::WARNING);
|
||||
if (readResult.error().status == Utils::IO::ReadError::NotExist)
|
||||
{
|
||||
loadLegacy();
|
||||
return;
|
||||
}
|
||||
|
||||
LogMsg(tr("Failed to load Watched Folders configuration. %1").arg(readResult.error().message), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError jsonError;
|
||||
const QJsonDocument jsonDoc = QJsonDocument::fromJson(confFile.readAll(), &jsonError);
|
||||
const QJsonDocument jsonDoc = QJsonDocument::fromJson(readResult.value(), &jsonError);
|
||||
if (jsonError.error != QJsonParseError::NoError)
|
||||
{
|
||||
LogMsg(tr("Couldn't parse Watched Folders configuration from %1. Error: %2")
|
||||
.arg(confFile.fileName(), jsonError.errorString()), Log::WARNING);
|
||||
LogMsg(tr("Failed to parse Watched Folders configuration from %1. Error: \"%2\"")
|
||||
.arg(path.toString(), jsonError.errorString()), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jsonDoc.isObject())
|
||||
{
|
||||
LogMsg(tr("Couldn't load Watched Folders configuration from %1. Invalid data format.")
|
||||
.arg(confFile.fileName()), Log::WARNING);
|
||||
LogMsg(tr("Failed to load Watched Folders configuration from %1. Error: \"Invalid data format.\"")
|
||||
.arg(path.toString()), Log::WARNING);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -426,17 +428,26 @@ void TorrentFilesWatcher::Worker::processFolder(const Path &path, const Path &wa
|
||||
|
||||
if (filePath.hasExtension(u".magnet"_qs))
|
||||
{
|
||||
const int fileMaxSize = 100 * 1024 * 1024;
|
||||
|
||||
QFile file {filePath.data()};
|
||||
if (file.open(QIODevice::ReadOnly | QIODevice::Text))
|
||||
{
|
||||
while (!file.atEnd())
|
||||
if (file.size() <= fileMaxSize)
|
||||
{
|
||||
const auto line = QString::fromLatin1(file.readLine()).trimmed();
|
||||
emit magnetFound(BitTorrent::MagnetUri(line), addTorrentParams);
|
||||
}
|
||||
while (!file.atEnd())
|
||||
{
|
||||
const auto line = QString::fromLatin1(file.readLine()).trimmed();
|
||||
emit magnetFound(BitTorrent::MagnetUri(line), addTorrentParams);
|
||||
}
|
||||
|
||||
file.close();
|
||||
Utils::Fs::removeFile(filePath);
|
||||
file.close();
|
||||
Utils::Fs::removeFile(filePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogMsg(tr("Magnet file too big. File: %1").arg(file.errorString()));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
#include <libtorrent/bencode.hpp>
|
||||
#include <libtorrent/entry.hpp>
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QByteArray>
|
||||
#include <QFile>
|
||||
#include <QFileDevice>
|
||||
#include <QSaveFile>
|
||||
#include <QString>
|
||||
@@ -69,6 +71,36 @@ Utils::IO::FileDeviceOutputIterator &Utils::IO::FileDeviceOutputIterator::operat
|
||||
return *this;
|
||||
}
|
||||
|
||||
nonstd::expected<QByteArray, Utils::IO::ReadError> Utils::IO::readFile(const Path &path, const qint64 maxSize, const QIODevice::OpenMode additionalMode)
|
||||
{
|
||||
QFile file {path.data()};
|
||||
if (!file.open(QIODevice::ReadOnly | additionalMode))
|
||||
{
|
||||
const QString message = QCoreApplication::translate("Utils::IO", "File open error. File: \"%1\". Error: \"%2\"")
|
||||
.arg(file.fileName(), file.errorString());
|
||||
return nonstd::make_unexpected(ReadError {ReadError::NotExist, message});
|
||||
}
|
||||
|
||||
const qint64 fileSize = file.size();
|
||||
if ((maxSize >= 0) && (fileSize > maxSize))
|
||||
{
|
||||
const QString message = QCoreApplication::translate("Utils::IO", "File size exceeds limit. File: \"%1\". File size: %2. Size limit: %3")
|
||||
.arg(file.fileName(), QString::number(fileSize), QString::number(maxSize));
|
||||
return nonstd::make_unexpected(ReadError {ReadError::ExceedSize, message});
|
||||
}
|
||||
|
||||
// Do not use `QIODevice::readAll()` it won't stop when reading `/dev/zero`
|
||||
const QByteArray data = file.read(fileSize);
|
||||
if (const qint64 dataSize = data.size(); dataSize != fileSize)
|
||||
{
|
||||
const QString message = QCoreApplication::translate("Utils::IO", "Read size mismatch. File: \"%1\". Expected: %2. Actual: %3")
|
||||
.arg(file.fileName(), QString::number(fileSize), QString::number(dataSize));
|
||||
return nonstd::make_unexpected(ReadError {ReadError::SizeMismatch, message});
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
nonstd::expected<void, QString> Utils::IO::saveToFile(const Path &path, const QByteArray &data)
|
||||
{
|
||||
if (const Path parentPath = path.parentPath(); !parentPath.isEmpty())
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
|
||||
#include <libtorrent/fwd.hpp>
|
||||
|
||||
#include <QIODevice>
|
||||
|
||||
#include "base/3rdparty/expected.hpp"
|
||||
#include "base/pathfwd.h"
|
||||
|
||||
@@ -81,6 +83,23 @@ namespace Utils::IO
|
||||
int m_bufferSize = 0;
|
||||
};
|
||||
|
||||
struct ReadError
|
||||
{
|
||||
enum Code
|
||||
{
|
||||
NotExist,
|
||||
ExceedSize,
|
||||
SizeMismatch
|
||||
};
|
||||
|
||||
Code status = {};
|
||||
QString message;
|
||||
};
|
||||
|
||||
// TODO: define a specific type for `additionalMode`
|
||||
// providing `size` is explicit and is strongly recommended
|
||||
nonstd::expected<QByteArray, ReadError> readFile(const Path &path, qint64 size, QIODevice::OpenMode additionalMode = {});
|
||||
|
||||
nonstd::expected<void, QString> saveToFile(const Path &path, const QByteArray &data);
|
||||
nonstd::expected<void, QString> saveToFile(const Path &path, const lt::entry &data);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user