Files
update-client/Common/ConfigHelper.cpp

654 lines
22 KiB
C++

#include "ConfigHelper.h"
#include "InitialStatePolicy.h"
#include <QCoreApplication>
#include <QDebug>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QJsonParseError>
#include <QRegularExpression>
#include <QSettings>
#include <QUrl>
#include <QUuid>
namespace
{
constexpr auto kProductConfigResource = ":/simcae/update-client/product-config.json";
constexpr auto kSettingsOrganization = "SimCAE";
constexpr auto kSettingsApplication = "UpdateClient";
constexpr auto kMigrationMarker = "meta/legacy_state_migration_version";
constexpr int kMigrationVersion = 1;
const QStringList& mutableKeys()
{
static const QStringList keys{
QStringLiteral("current_version"),
QStringLiteral("license_key"),
QStringLiteral("device_id"),
QStringLiteral("installation_id")
};
return keys;
}
const QStringList& legacyMigratableKeys()
{
static const QStringList keys{
QStringLiteral("license_key"),
QStringLiteral("device_id"),
QStringLiteral("installation_id")
};
return keys;
}
QString settingsStatusText(QSettings::Status status)
{
switch (status)
{
case QSettings::NoError:
return QStringLiteral("no error");
case QSettings::AccessError:
return QStringLiteral("access denied");
case QSettings::FormatError:
return QStringLiteral("invalid settings format");
}
return QStringLiteral("unknown settings error");
}
bool isSafeIdentifier(const QString& value)
{
static const QRegularExpression pattern(QStringLiteral("^[A-Za-z0-9._-]+$"));
return pattern.match(value).hasMatch();
}
bool isStrictRelativePath(const QString& value)
{
const QString relative = QDir::fromNativeSeparators(value.trimmed());
if (relative.isEmpty()
|| QDir::isAbsolutePath(relative)
|| relative.contains(QLatin1Char(':'))
|| QDir::cleanPath(relative) != relative)
{
return false;
}
const QStringList segments = relative.split(QLatin1Char('/'));
for (const QString& segment : segments)
{
if (segment.isEmpty() || segment == QStringLiteral(".") || segment == QStringLiteral(".."))
return false;
}
return true;
}
bool isPathWithinRoot(const QString& rootPath, const QString& candidatePath)
{
const QString relative = QDir::fromNativeSeparators(
QDir(rootPath).relativeFilePath(candidatePath));
return !QDir::isAbsolutePath(relative)
&& relative != QStringLiteral("..")
&& !relative.startsWith(QStringLiteral("../"));
}
bool isControlledMainExecutablePath(const QString& value, const QString& installRoot)
{
const QString relative = QDir::fromNativeSeparators(value.trimmed());
if (relative.isEmpty()
|| QDir::isAbsolutePath(relative)
|| relative.contains(QLatin1Char(':'))
|| QDir::cleanPath(relative) != relative
|| relative == QStringLiteral(".")
|| relative == QStringLiteral(".."))
{
return false;
}
const QString runtimeRoot = QDir::cleanPath(QCoreApplication::applicationDirPath());
const QString approvedRoot = QDir::cleanPath(QDir(runtimeRoot).filePath(installRoot));
const QString executablePath = QDir::cleanPath(QDir(runtimeRoot).filePath(relative));
return isPathWithinRoot(approvedRoot, executablePath);
}
bool isPositiveInteger(const QJsonValue& value)
{
if (!value.isString() && !value.isDouble())
return false;
bool ok = false;
const int number = value.toVariant().toString().toInt(&ok);
return ok && number > 0;
}
bool hasControlCharacter(const QString& value)
{
for (const QChar character : value)
{
if (character.category() == QChar::Other_Control)
return true;
}
return false;
}
bool normalizeMutableStateValue(const QString& key, const QString& rawValue, QString* normalizedValue)
{
const QString value = rawValue.trimmed();
bool valid = false;
if (key == QStringLiteral("current_version"))
{
static const QRegularExpression versionPattern(
QStringLiteral("^[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"));
valid = versionPattern.match(value).hasMatch();
}
else if (key == QStringLiteral("installation_id"))
{
valid = value.isEmpty() || !QUuid(value).isNull();
}
else if (key == QStringLiteral("device_id"))
{
valid = value.size() <= 256 && !hasControlCharacter(value);
}
else if (key == QStringLiteral("license_key"))
{
valid = value.size() <= 4096 && !hasControlCharacter(value);
}
if (valid && normalizedValue)
*normalizedValue = value;
return valid;
}
}
ConfigHelper& ConfigHelper::instance()
{
static ConfigHelper obj;
return obj;
}
ConfigHelper::ConfigHelper()
: m_productConfigPath(QString::fromLatin1(kProductConfigResource))
{
if (!loadProductConfig())
{
qCritical().noquote() << m_error;
return;
}
m_statePrefix = QStringLiteral("products/%1/%2/")
.arg(m_productConfig.value(QStringLiteral("app_id")).toString(),
m_productConfig.value(QStringLiteral("channel")).toString());
if (!migrateLegacyStateIfNeeded())
qWarning().noquote() << m_error;
if (!initializeStateDefaults())
qWarning().noquote() << m_error;
}
QString ConfigHelper::configPath() const
{
return stateLocation();
}
QString ConfigHelper::productConfigPath() const
{
return m_productConfigPath;
}
QString ConfigHelper::stateLocation() const
{
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
const QString nativeLocation = settings.fileName();
if (!nativeLocation.isEmpty())
return nativeLocation;
return QStringLiteral("Native system settings: %1/%2")
.arg(QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
}
QString ConfigHelper::installRoot() const
{
QString relativeRoot = getValue(QStringLiteral("Runtime"), QStringLiteral("install_root")).trimmed();
if (relativeRoot.isEmpty())
relativeRoot = QStringLiteral(".");
return QDir::cleanPath(QDir(runtimeRoot()).filePath(relativeRoot));
}
QString ConfigHelper::runtimeRoot() const
{
return QDir::cleanPath(QCoreApplication::applicationDirPath());
}
QString ConfigHelper::updateRoot() const
{
return QDir(runtimeRoot()).filePath(QStringLiteral("update"));
}
QString ConfigHelper::runtimeRelativePath() const
{
QString relative = QDir::fromNativeSeparators(QDir(installRoot()).relativeFilePath(runtimeRoot()));
relative = QDir::cleanPath(relative);
if (relative == QStringLiteral("."))
return QString();
while (relative.startsWith(QStringLiteral("./")))
relative = relative.mid(2);
return relative.trimmed();
}
QString ConfigHelper::lastError() const
{
return m_error;
}
QString ConfigHelper::getValue(const QString& section, const QString& key) const
{
Q_UNUSED(section);
if (isMutableKey(key))
return stateValue(key);
return m_productConfig.value(key).toVariant().toString();
}
bool ConfigHelper::setValue(const QString& section, const QString& key, const QString& value)
{
Q_UNUSED(section);
m_error.clear();
if (!isMutableKey(key))
{
m_error = QStringLiteral("Configuration key '%1' is immutable and cannot be changed at runtime.")
.arg(key);
return false;
}
if (m_statePrefix.isEmpty())
{
m_error = QStringLiteral("Runtime state is unavailable because product configuration was not loaded.");
return false;
}
QString normalizedValue;
if (!normalizeMutableStateValue(key, value, &normalizedValue))
{
m_error = QStringLiteral("Runtime state key '%1' has an invalid value.").arg(key);
return false;
}
return writeStateValue(key, normalizedValue);
}
bool ConfigHelper::loadProductConfig()
{
QFile file(m_productConfigPath);
if (!file.open(QIODevice::ReadOnly))
{
m_error = QStringLiteral("Cannot open embedded product configuration '%1': %2")
.arg(m_productConfigPath, file.errorString());
return false;
}
QJsonParseError parseError;
const QJsonDocument document = QJsonDocument::fromJson(file.readAll(), &parseError);
if (parseError.error != QJsonParseError::NoError || !document.isObject())
{
m_error = QStringLiteral("Embedded product configuration is invalid JSON at offset %1: %2")
.arg(parseError.offset)
.arg(parseError.errorString());
return false;
}
const QJsonObject config = document.object();
const QJsonValue schemaVersion = config.value(QStringLiteral("schema_version"));
if (!schemaVersion.isDouble() || schemaVersion.toDouble(-1.0) != 1.0)
{
m_error = QStringLiteral("Embedded product configuration schema_version must be 1.");
return false;
}
const QStringList requiredTextKeys{
QStringLiteral("app_id"),
QStringLiteral("app_name"),
QStringLiteral("channel"),
QStringLiteral("launch_token"),
QStringLiteral("api_base_url"),
QStringLiteral("client_token"),
QStringLiteral("temp_folder"),
QStringLiteral("install_root"),
QStringLiteral("main_executable"),
QStringLiteral("launcher_executable"),
QStringLiteral("updater_executable"),
QStringLiteral("bootstrap_executable"),
QStringLiteral("platform"),
QStringLiteral("arch")
};
for (const QString& key : requiredTextKeys)
{
const QJsonValue value = config.value(key);
if (!value.isString() || value.toString().trimmed().isEmpty())
{
m_error = QStringLiteral("Embedded product configuration key '%1' must be a non-empty string.")
.arg(key);
return false;
}
}
for (const QString& key : mutableKeys())
{
if (config.contains(key))
{
m_error = QStringLiteral("Mutable key '%1' must not appear in the product configuration root.")
.arg(key);
return false;
}
}
if (!isSafeIdentifier(config.value(QStringLiteral("app_id")).toString())
|| !isSafeIdentifier(config.value(QStringLiteral("channel")).toString()))
{
m_error = QStringLiteral("Product app_id and channel may contain only letters, digits, '.', '_' and '-'.");
return false;
}
const QUrl apiUrl(config.value(QStringLiteral("api_base_url")).toString());
if (!apiUrl.isValid()
|| apiUrl.host().isEmpty()
|| (apiUrl.scheme().compare(QStringLiteral("http"), Qt::CaseInsensitive) != 0
&& apiUrl.scheme().compare(QStringLiteral("https"), Qt::CaseInsensitive) != 0))
{
m_error = QStringLiteral("Product api_base_url must be a valid HTTP or HTTPS URL.");
return false;
}
const QStringList positiveIntegerKeys{
QStringLiteral("client_protocol"),
QStringLiteral("request_timeout_ms"),
QStringLiteral("health_check_timeout_ms")
};
for (const QString& key : positiveIntegerKeys)
{
if (!isPositiveInteger(config.value(key)))
{
m_error = QStringLiteral("Embedded product configuration key '%1' must be a positive integer.")
.arg(key);
return false;
}
}
const QString installRoot = QDir::fromNativeSeparators(
config.value(QStringLiteral("install_root")).toString().trimmed());
if (QDir::isAbsolutePath(installRoot)
|| installRoot.contains(QLatin1Char(':'))
|| QDir::cleanPath(installRoot) != installRoot)
{
m_error = QStringLiteral("Product install_root must be a normalized relative path.");
return false;
}
if (!isSafeIdentifier(config.value(QStringLiteral("platform")).toString())
|| !isSafeIdentifier(config.value(QStringLiteral("arch")).toString()))
{
m_error = QStringLiteral("Product platform and arch may contain only letters, digits, '.', '_' and '-'.");
return false;
}
if (!isControlledMainExecutablePath(
config.value(QStringLiteral("main_executable")).toString(), installRoot))
{
m_error = QStringLiteral(
"Embedded product configuration key 'main_executable' must stay within install_root.");
return false;
}
const QStringList restrictedRelativePaths{
QStringLiteral("temp_folder"),
QStringLiteral("launcher_executable"),
QStringLiteral("updater_executable"),
QStringLiteral("bootstrap_executable")
};
for (const QString& key : restrictedRelativePaths)
{
if (!isStrictRelativePath(config.value(key).toString()))
{
m_error = QStringLiteral("Embedded product configuration key '%1' must be a safe relative path.")
.arg(key);
return false;
}
}
const QJsonValue initialStateValue = config.value(QStringLiteral("initial_state"));
if (!initialStateValue.isObject())
{
m_error = QStringLiteral("Embedded product configuration must contain an initial_state object.");
return false;
}
const QJsonObject initialState = initialStateValue.toObject();
QJsonObject normalizedInitialState;
for (auto it = initialState.constBegin(); it != initialState.constEnd(); ++it)
{
if (!isMutableKey(it.key()) || !it.value().isString())
{
m_error = QStringLiteral("Initial state key '%1' is unsupported or is not a string.").arg(it.key());
return false;
}
QString normalizedValue;
if (!normalizeMutableStateValue(it.key(), it.value().toString(), &normalizedValue))
{
m_error = QStringLiteral("Initial state key '%1' has an invalid value.").arg(it.key());
return false;
}
normalizedInitialState.insert(it.key(), normalizedValue);
}
if (!normalizedInitialState.contains(QStringLiteral("current_version")))
{
m_error = QStringLiteral("Initial state current_version must be a non-empty string.");
return false;
}
m_productConfig = config;
m_productConfig.remove(QStringLiteral("initial_state"));
m_initialState = normalizedInitialState;
return true;
}
bool ConfigHelper::migrateLegacyStateIfNeeded()
{
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
const QString markerKey = m_statePrefix + QString::fromLatin1(kMigrationMarker);
if (settings.value(markerKey).toInt() >= kMigrationVersion)
return true;
bool allStateKeysPresent = true;
for (const QString& key : legacyMigratableKeys())
allStateKeysPresent = allStateKeysPresent && settings.contains(stateStorageKey(key));
QJsonObject iniValues;
QJsonObject jsonValues;
const QString legacyIniPath = QDir(QCoreApplication::applicationDirPath())
.filePath(QStringLiteral("client.ini"));
if (!allStateKeysPresent && QFile::exists(legacyIniPath))
{
QSettings ini(legacyIniPath, QSettings::IniFormat);
ini.setFallbacksEnabled(false);
const auto importIniValue = [&](const QString& section, const QString& key) {
const QString legacyKey = section + QLatin1Char('/') + key;
if (ini.contains(legacyKey))
iniValues.insert(key, ini.value(legacyKey).toString());
};
importIniValue(QStringLiteral("License"), QStringLiteral("license_key"));
importIniValue(QStringLiteral("Update"), QStringLiteral("device_id"));
importIniValue(QStringLiteral("Device"), QStringLiteral("installation_id"));
if (ini.status() != QSettings::NoError)
qWarning().noquote() << QStringLiteral("Cannot read legacy INI state '%1': %2")
.arg(legacyIniPath, settingsStatusText(ini.status()));
}
const QString legacyJsonPath = QDir(QCoreApplication::applicationDirPath())
.filePath(QStringLiteral("config/app_config.json"));
QFile legacyJson(legacyJsonPath);
if (!allStateKeysPresent && legacyJson.exists())
{
if (!legacyJson.open(QIODevice::ReadOnly))
{
qWarning().noquote() << QStringLiteral("Cannot read legacy JSON state '%1': %2")
.arg(legacyJsonPath, legacyJson.errorString());
}
else
{
QJsonParseError legacyParseError;
const QJsonDocument legacyDocument = QJsonDocument::fromJson(legacyJson.readAll(), &legacyParseError);
if (legacyParseError.error != QJsonParseError::NoError || !legacyDocument.isObject())
{
qWarning().noquote() << QStringLiteral("Cannot parse legacy JSON state '%1' at offset %2: %3")
.arg(legacyJsonPath)
.arg(legacyParseError.offset)
.arg(legacyParseError.errorString());
}
else
{
const QJsonObject legacy = legacyDocument.object();
for (const QString& key : legacyMigratableKeys())
{
const QJsonValue value = legacy.value(key);
if (value.isString())
jsonValues.insert(key, value.toString());
}
}
}
}
QStringList migratedKeys;
for (const QString& key : legacyMigratableKeys())
{
const QString storageKey = stateStorageKey(key);
if (settings.contains(storageKey))
continue;
QString normalizedValue;
bool hasValue = false;
if (jsonValues.contains(key))
{
hasValue = normalizeMutableStateValue(key, jsonValues.value(key).toString(), &normalizedValue);
if (!hasValue)
qWarning().noquote() << QStringLiteral("Rejected invalid legacy JSON state key '%1' from '%2'.")
.arg(key, legacyJsonPath);
}
if (!hasValue && iniValues.contains(key))
{
hasValue = normalizeMutableStateValue(key, iniValues.value(key).toString(), &normalizedValue);
if (!hasValue)
qWarning().noquote() << QStringLiteral("Rejected invalid legacy INI state key '%1' from '%2'.")
.arg(key, legacyIniPath);
}
if (hasValue)
{
settings.setValue(storageKey, normalizedValue);
migratedKeys.append(key);
}
}
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot persist legacy runtime state migration in system settings: %1")
.arg(settingsStatusText(settings.status()));
return false;
}
settings.setValue(markerKey, kMigrationVersion);
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot mark legacy runtime state migration complete in system settings: %1")
.arg(settingsStatusText(settings.status()));
return false;
}
if (!migratedKeys.isEmpty())
qInfo().noquote() << QStringLiteral("Migrated legacy runtime state keys: %1")
.arg(migratedKeys.join(QStringLiteral(", ")));
return true;
}
bool ConfigHelper::initializeStateDefaults()
{
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
for (const QString& key : mutableKeys())
{
if (!m_initialState.contains(key))
continue;
const QString storageKey = stateStorageKey(key);
const bool storedValueExists = settings.contains(storageKey);
const QString storedValue = storedValueExists
? settings.value(storageKey).toString()
: QString();
const QString initialValue = m_initialState.value(key).toString();
if (UpdateClient::shouldApplyInitialStateValue(
key, storedValueExists, storedValue, initialValue))
{
settings.setValue(storageKey, initialValue);
}
}
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot initialize runtime state in system settings: %1")
.arg(settingsStatusText(settings.status()));
return false;
}
return true;
}
QString ConfigHelper::stateStorageKey(const QString& key) const
{
return m_statePrefix + QStringLiteral("state/") + key;
}
QString ConfigHelper::stateValue(const QString& key) const
{
if (m_statePrefix.isEmpty())
return QString();
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
const QString storageKey = stateStorageKey(key);
if (settings.contains(storageKey))
return settings.value(storageKey).toString();
return QString();
}
bool ConfigHelper::writeStateValue(const QString& key, const QString& value)
{
QSettings settings(QSettings::NativeFormat, QSettings::SystemScope,
QString::fromLatin1(kSettingsOrganization),
QString::fromLatin1(kSettingsApplication));
settings.setFallbacksEnabled(false);
settings.setValue(stateStorageKey(key), value);
settings.sync();
if (settings.status() != QSettings::NoError)
{
m_error = QStringLiteral("Cannot write runtime state key '%1' to system settings: %2")
.arg(key, settingsStatusText(settings.status()));
return false;
}
return true;
}
bool ConfigHelper::isMutableKey(const QString& key)
{
return mutableKeys().contains(key);
}