#include "ConfigHelper.h" #include "InitialStatePolicy.h" #include #include #include #include #include #include #include #include #include #include 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); }