Files
update-client/Updater/UpdaterLogic.cpp
T
Comely fe5aa5bbf0 feat(hardening): 内嵌产品配置 + 系统级状态 + 全量 i18n + 依赖治理 + 资料清理
update-client-hardening 四工作流合入(构建/静态检查已过):

- 配置:不可变产品策略经 UpdateClientResources.cmake 归一化校验后
  内嵌 qrc(URL/相对路径/主程序必须落 install_root/版本/UUID 格式,
  CMake override 写入前复验);可变机器状态迁系统级原生存储;
  运行目录不再落可编辑 app_config.json。
- i18n:生产源全英文(no-Han 检查 41/41),中文进 translations/
  zh_CN 目录;翻译加载收敛 Common/TranslationHelper 唯一入口;
  Bootstrap 补资源文件。
- 依赖:Qt/OpenSSL/架构/Perl 机器路径改 imported targets +
  UpdateClientDependencies.cmake,3rdparty 由父工程契约供给。
- 清理:旧 README/SDK README/3 个重复 PowerShell 打包脚本/PDF 提取
  文本删除,canonical README + 5 份结构化英文文档替代;Launcher
  requireAdministrator manifest;统一 MSVC /utf-8 删运行时编码设置。
2026-07-10 00:38:23 -07:00

1002 lines
41 KiB
C++

#include "UpdaterLogic.h"
#include <QSet>
#include <QDebug>
#include <QFileInfo>
#include <QFile>
#include <QEventLoop>
#include <QElapsedTimer>
#include <QThread>
#include <QNetworkRequest>
#include <QNetworkProxy>
#include <QJsonArray>
#include <QCryptographicHash>
#include <QDir>
#include <QJsonDocument>
#include <QSaveFile>
#include <QApplication>
#include <QCoreApplication>
#include <QUrl>
#include <algorithm>
#include <cmath>
#include "ConfigHelper.h"
#ifdef HAVE_OPENSSL
#include <openssl/pem.h>
#include <openssl/evp.h>
#include <openssl/bio.h>
#include <openssl/err.h>
#endif
namespace
{
bool jsonNonNegativeInteger(const QJsonValue& value, qint64* result)
{
constexpr double maxExactJsonInteger = 9007199254740991.0;
if (!value.isDouble())
return false;
const double number = value.toDouble();
if (!std::isfinite(number) || number < 0.0 || number > maxExactJsonInteger
|| std::floor(number) != number)
return false;
if (result)
*result = static_cast<qint64>(number);
return true;
}
bool isSha256(const QString& value)
{
if (value.size() != 64)
return false;
for (const QChar ch : value)
{
const ushort code = ch.unicode();
if (!((code >= '0' && code <= '9')
|| (code >= 'a' && code <= 'f')
|| (code >= 'A' && code <= 'F')))
return false;
}
return true;
}
}
UpdaterLogic::UpdaterLogic(QObject* parent)
: QObject(parent)
{
m_serverAddr = ConfigHelper::instance().getValue("Server", "api_base_url");
}
void UpdaterLogic::getManifest(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{
m_manifest = QJsonObject();
m_manifestText.clear();
m_fileItems.clear();
m_manifestSignatureVerified = false;
QString url = m_serverAddr + "/api/v1/update/manifest";
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = targetVer;
body["version_id"] = versionId;
m_http.postRequest(url, body, [this, appId, channel, targetVer, versionId](int code, const QJsonObject& resp)
{
qDebug() << "Manifest API returned code:" << code;
m_manifest = QJsonObject();
m_manifestText.clear();
m_fileItems.clear();
m_manifestSignatureVerified = false;
if (code == 200)
{
const QJsonValue manifestTextValue = resp.value("manifest_text");
const QJsonValue responseManifestValue = resp.value("manifest");
if (!manifestTextValue.isString() || !responseManifestValue.isObject())
{
qDebug() << "Manifest response fields have invalid types";
}
else
{
const QString manifestText = manifestTextValue.toString();
const QJsonObject responseManifest = responseManifestValue.toObject();
const QJsonValue signatureValue = responseManifest.value("signature");
QJsonParseError parseError;
const QJsonDocument manifestDocument =
QJsonDocument::fromJson(manifestText.toUtf8(), &parseError);
if (manifestText.isEmpty() || !signatureValue.isString()
|| signatureValue.toString().trimmed().isEmpty()
|| parseError.error != QJsonParseError::NoError
|| !manifestDocument.isObject())
{
qDebug() << "Manifest response JSON or signature field is invalid";
}
else
{
QJsonObject trustedManifest = manifestDocument.object();
QList<FileDownloadItem> trustedFiles;
if (trustedManifest.contains("signature"))
{
qDebug() << "Signed manifest text unexpectedly contains a signature field";
}
else
{
trustedManifest.insert("signature", signatureValue.toString());
if (validateOnlineManifest(trustedManifest, appId, channel,
targetVer, versionId, &trustedFiles))
{
m_manifestText = manifestText;
m_manifest = trustedManifest;
m_fileItems = trustedFiles;
qDebug() << "Received trusted manifest version:"
<< m_manifest.value("version").toString();
}
else
{
qDebug() << "Manifest fields or request identity are invalid";
}
}
}
}
}
else
{
qDebug() << "Failed to get manifest";
}
emit fetchUrlFinished();
});
}
bool UpdaterLogic::validateOnlineManifest(const QJsonObject& manifest, const QString& appId,
const QString& channel, const QString& targetVer,
int versionId, QList<FileDownloadItem>* fileItems) const
{
if (!fileItems)
return false;
fileItems->clear();
const QJsonValue appValue = manifest.value("app_id");
const QJsonValue channelValue = manifest.value("channel");
const QJsonValue versionValue = manifest.value("version");
const QJsonValue sequenceValue = manifest.value("manifest_seq");
const QJsonValue filesValue = manifest.value("files");
const QJsonValue signatureValue = manifest.value("signature");
qint64 manifestSequence = -1;
if (!appValue.isString() || appValue.toString().isEmpty()
|| !channelValue.isString() || channelValue.toString().isEmpty()
|| !versionValue.isString() || versionValue.toString().isEmpty()
|| !jsonNonNegativeInteger(sequenceValue, &manifestSequence)
|| !filesValue.isArray()
|| !signatureValue.isString() || signatureValue.toString().trimmed().isEmpty()
|| appValue.toString() != appId
|| channelValue.toString() != channel
|| versionValue.toString() != targetVer
|| manifestSequence != static_cast<qint64>(versionId))
return false;
QSet<QString> pathKeys;
const QJsonArray files = filesValue.toArray();
fileItems->reserve(files.size());
for (const QJsonValue& value : files)
{
if (!value.isObject())
{
fileItems->clear();
return false;
}
const QJsonObject file = value.toObject();
const QJsonValue pathValue = file.value("path");
const QJsonValue shaValue = file.value("sha256");
const QJsonValue sizeValue = file.value("size");
qint64 size = -1;
if (!pathValue.isString() || pathValue.toString().isEmpty()
|| !shaValue.isString() || !isSha256(shaValue.toString())
|| !jsonNonNegativeInteger(sizeValue, &size))
{
fileItems->clear();
return false;
}
const QString path = pathValue.toString();
const QString normalizedPath = QDir::cleanPath(QDir::fromNativeSeparators(path));
const QString pathKey = normalizedPath.toCaseFolded();
if (!isSafeRelativePath(path) || pathKeys.contains(pathKey))
{
fileItems->clear();
return false;
}
pathKeys.insert(pathKey);
FileDownloadItem item;
item.path = path;
item.sha256 = shaValue.toString();
item.size = size;
fileItems->append(item);
}
return true;
}
bool UpdaterLogic::verifySignature(const QByteArray& payload, const QString& signatureBase64, const QString& publicKeyPath) const
{
#ifndef HAVE_OPENSSL
Q_UNUSED(payload);
Q_UNUSED(signatureBase64);
Q_UNUSED(publicKeyPath);
qDebug() << "OpenSSL not available, cannot verify manifest signature";
return false;
#else
QFile keyFile(publicKeyPath);
if (!keyFile.open(QIODevice::ReadOnly))
{
qDebug() << "Cannot open public key file:" << publicKeyPath;
return false;
}
QByteArray keyData = keyFile.readAll();
keyFile.close();
BIO* bio = BIO_new_mem_buf(keyData.constData(), keyData.size());
if (!bio)
{
qDebug() << "BIO_new_mem_buf failed";
return false;
}
EVP_PKEY* pkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL);
BIO_free(bio);
if (!pkey)
{
qDebug() << "PEM_read_bio_PUBKEY failed";
return false;
}
QByteArray signature = QByteArray::fromBase64(signatureBase64.toUtf8());
EVP_MD_CTX* mdctx = EVP_MD_CTX_new();
if (!mdctx)
{
EVP_PKEY_free(pkey);
qDebug() << "EVP_MD_CTX_new failed";
return false;
}
bool ok = false;
if (EVP_DigestVerifyInit(mdctx, NULL, EVP_sha256(), NULL, pkey) == 1)
{
if (EVP_DigestVerifyUpdate(mdctx, payload.constData(), payload.size()) == 1)
{
if (EVP_DigestVerifyFinal(mdctx, reinterpret_cast<const unsigned char*>(signature.constData()), signature.size()) == 1)
{
ok = true;
}
}
}
EVP_MD_CTX_free(mdctx);
EVP_PKEY_free(pkey);
if (!ok)
{
qDebug() << "Manifest signature verification failed";
}
return ok;
#endif
}
bool UpdaterLogic::verifyManifestSignature(const QString& publicKeyPath) const
{
m_manifestSignatureVerified = false;
if (m_manifest.isEmpty() || m_manifestText.isEmpty())
{
qDebug() << "No manifest available to verify";
return false;
}
QString signature = m_manifest.value("signature").toString();
if (signature.isEmpty())
{
qDebug() << "Manifest signature empty";
return false;
}
m_manifestSignatureVerified = verifySignature(m_manifestText.toUtf8(), signature, publicKeyPath);
return m_manifestSignatureVerified;
}
bool UpdaterLogic::saveManifestCache(const QString& cacheDir) const
{
if (m_manifest.isEmpty() || !m_manifestSignatureVerified)
return false;
QDir dir(cacheDir);
if (!dir.exists() && !dir.mkpath("."))
return false;
QString version = m_manifest.value("version").toString();
QString filePath = cacheDir + "/manifest_" + version + ".json";
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
return false;
QJsonObject wrapper;
wrapper["manifest"] = m_manifest;
wrapper["manifest_text"] = m_manifestText;
QJsonDocument doc(wrapper);
file.write(doc.toJson(QJsonDocument::Indented));
file.close();
qDebug() << "Manifest cached to" << filePath;
return true;
}
bool UpdaterLogic::loadManifestCache(const QString& cacheDir, const QString& version)
{
m_manifest = QJsonObject();
m_manifestText.clear();
m_fileItems.clear();
m_manifestSignatureVerified = false;
QString filePath = cacheDir + "/manifest_" + version + ".json";
QFile file(filePath);
if (!file.exists() || !file.open(QIODevice::ReadOnly))
return false;
QByteArray raw = file.readAll();
file.close();
QJsonParseError wrapperError;
const QJsonDocument doc = QJsonDocument::fromJson(raw, &wrapperError);
if (wrapperError.error != QJsonParseError::NoError || !doc.isObject())
return false;
const QJsonObject wrapper = doc.object();
const QJsonValue manifestTextValue = wrapper.value("manifest_text");
const QJsonValue cachedManifestValue = wrapper.value("manifest");
if (!manifestTextValue.isString() || manifestTextValue.toString().isEmpty()
|| !cachedManifestValue.isObject())
return false;
const QJsonValue signatureValue = cachedManifestValue.toObject().value("signature");
QJsonParseError manifestError;
const QString manifestText = manifestTextValue.toString();
const QJsonDocument manifestDocument =
QJsonDocument::fromJson(manifestText.toUtf8(), &manifestError);
if (!signatureValue.isString() || signatureValue.toString().trimmed().isEmpty()
|| manifestError.error != QJsonParseError::NoError
|| !manifestDocument.isObject())
return false;
QJsonObject trustedManifest = manifestDocument.object();
const QJsonValue versionValue = trustedManifest.value("version");
if (trustedManifest.contains("signature") || !versionValue.isString()
|| versionValue.toString() != version)
return false;
trustedManifest.insert("signature", signatureValue.toString());
m_manifest = trustedManifest;
m_manifestText = manifestText;
qDebug() << "Loaded cached manifest" << version;
return true;
}
QByteArray UpdaterLogic::canonicalManifestBytes(const QJsonObject& manifest) const
{
QJsonObject copy = manifest;
copy.remove("signature");
auto sortObject = [&](auto&& self, const QJsonObject& obj) -> QJsonObject {
QStringList keys = obj.keys();
std::sort(keys.begin(), keys.end(), std::less<QString>());
QJsonObject sorted;
for (const auto& key : keys)
{
QJsonValue value = obj.value(key);
if (value.isObject())
sorted.insert(key, self(self, value.toObject()));
else if (value.isArray())
{
QJsonArray sortedArray;
for (const auto& element : value.toArray())
{
if (element.isObject())
sortedArray.append(self(self, element.toObject()));
else
sortedArray.append(element);
}
sorted.insert(key, sortedArray);
}
else
{
sorted.insert(key, value);
}
}
return sorted;
};
QJsonObject sorted = sortObject(sortObject, copy);
QJsonDocument doc(sorted);
return doc.toJson(QJsonDocument::Compact);
}
QStringList UpdaterLogic::obsoleteFilesComparedTo(const QJsonObject& oldManifest) const
{
QSet<QString> newPaths;
for (const QJsonValue& value : m_manifest.value("files").toArray()) {
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString());
if (isSafeRelativePath(path)) newPaths.insert(path.toCaseFolded());
}
QSet<QString> protectedPaths{
QStringLiteral("bootstrap.exe"),
QStringLiteral("launcher.exe"),
QStringLiteral("updater.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("launcher.exe"), QStringLiteral("updater.exe"),
QStringLiteral("client.ini"), QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"), QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
for (const QString& path : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + path);
}
QStringList obsolete;
QSet<QString> seen;
for (const QJsonValue& value : oldManifest.value("files").toArray()) {
const QString path = QDir::fromNativeSeparators(value.toObject().value("path").toString());
const QString folded = path.toCaseFolded();
if (!isSafeRelativePath(path) || protectedPaths.contains(folded)
|| newPaths.contains(folded) || seen.contains(folded))
continue;
seen.insert(folded);
obsolete.append(path);
}
obsolete.sort(Qt::CaseInsensitive);
return obsolete;
}
bool UpdaterLogic::validateLocalFiles(const QString& stagingDir, const QString& installedDir) const
{
if (m_manifest.isEmpty())
return false;
const QJsonArray files = m_manifest.value("files").toArray();
for (const auto& item : files)
{
const QJsonObject fileObject = item.toObject();
const QString path = QDir::fromNativeSeparators(fileObject.value("path").toString());
const QString expectedSha = fileObject.value("sha256").toString();
if (!isSafeRelativePath(path))
return false;
if (isRuntimeProtectedPath(path)) {
qDebug() << "Runtime-protected manifest entry ignored:" << path;
continue;
}
QString fullPath = QDir(stagingDir).filePath(path);
if (!QFile::exists(fullPath) && !installedDir.isEmpty())
fullPath = QDir(installedDir).filePath(path);
if (!QFile::exists(fullPath))
{
qDebug() << "Manifest file missing from staging and installation:" << path;
return false;
}
const QString actualSha = calcLocalFileSha256(fullPath);
if (actualSha.compare(expectedSha, Qt::CaseInsensitive) != 0)
{
qDebug() << "File hash mismatch:" << path << actualSha << expectedSha;
return false;
}
}
qDebug() << "Full manifest validation passed using staging plus installed files";
return true;
}
bool UpdaterLogic::loadOfflinePackage(const QString& packagePath, const QString& stagingDir)
{
m_offlineError.clear();
QFile package(packagePath);
if (!package.open(QIODevice::ReadOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot open the offline update package."); return false; }
if (package.read(8) != QByteArray("MUPD0001", 8)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package format identifier is invalid."); return false; }
const QByteArray lengthBytes = package.read(8);
if (lengthBytes.size() != 8) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header is incomplete."); return false; }
quint64 headerSize = 0;
for (int i = 0; i < 8; ++i) headerSize |= quint64(static_cast<unsigned char>(lengthBytes[i])) << (i * 8);
if (headerSize == 0 || headerSize > 64ULL * 1024 * 1024 || headerSize > quint64(package.size() - 16)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header length is invalid."); return false; }
QJsonParseError error;
const QJsonDocument wrapperDoc = QJsonDocument::fromJson(package.read(qint64(headerSize)), &error);
if (error.error != QJsonParseError::NoError || !wrapperDoc.isObject()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package header contains invalid JSON."); return false; }
const QJsonObject wrapper = wrapperDoc.object();
const QByteArray packageText = wrapper.value("package_text").toString().toUtf8();
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
const QString publicKey = QStringLiteral(":/simcae/update-client/manifest-public-key.pem");
if (!verifySignature(packageText, wrapper.value("package_signature").toString(), publicKey)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package RSA signature is invalid."); return false; }
const QJsonObject packageMeta = QJsonDocument::fromJson(packageText, &error).object();
if (error.error != QJsonParseError::NoError || packageMeta.value("format").toString() != "MUPD0001") { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The signed offline package metadata is invalid."); return false; }
if (QString::fromLatin1(QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex()) != packageMeta.value("manifest_sha256").toString()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The manifest digest does not match the package signature."); return false; }
QJsonObject manifest = QJsonDocument::fromJson(manifestText, &error).object();
if (error.error != QJsonParseError::NoError || manifest.isEmpty()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest is invalid."); return false; }
manifest.insert("signature", wrapper.value("manifest_signature").toString());
m_manifest = manifest; m_manifestText = QString::fromUtf8(manifestText); m_fileItems.clear();
if (manifest.value("app_id") != packageMeta.value("app_id") || manifest.value("channel") != packageMeta.value("channel") || manifest.value("version") != packageMeta.value("version")) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The package metadata and manifest identity do not match."); return false; }
if (!verifyManifestSignature()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline manifest RSA signature is invalid."); return false; }
const qint64 payloadStart = 16 + qint64(headerSize);
const QJsonArray entries = packageMeta.value("files").toArray();
for (const QJsonValue& value : entries) {
const QJsonObject item = value.toObject(); const QString path = QDir::fromNativeSeparators(item.value("path").toString());
const qint64 offset = item.value("offset").toVariant().toLongLong(); const qint64 size = item.value("size").toVariant().toLongLong();
if (!isSafeRelativePath(path) || offset < 0 || size < 0 || payloadStart + offset + size > package.size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The offline package contains an unsafe path or out-of-bounds data: %1").arg(path); return false; }
FileDownloadItem fi{path, QString(), item.value("sha256").toString(), size}; m_fileItems.append(fi);
if (stagingDir.isEmpty()) continue;
const QString target = QDir(stagingDir).filePath(path); if (!QDir().mkpath(QFileInfo(target).path()) || !package.seek(payloadStart + offset)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot prepare the offline file: %1").arg(path); return false; }
QSaveFile output(target); if (!output.open(QIODevice::WriteOnly)) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "Cannot create the staged file: %1").arg(path); return false; }
QCryptographicHash hash(QCryptographicHash::Sha256); qint64 remaining = size;
while (remaining > 0) { const QByteArray block = package.read(qMin<qint64>(remaining, 1024 * 1024)); if (block.isEmpty() || output.write(block) != block.size()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Failed to read the offline file: %1").arg(path); return false; } hash.addData(block); remaining -= block.size(); }
if (QString::fromLatin1(hash.result().toHex()).compare(fi.sha256, Qt::CaseInsensitive) != 0 || !output.commit()) { output.cancelWriting(); m_offlineError = QCoreApplication::translate("UpdaterLogic", "Offline file hash validation or writing failed: %1").arg(path); return false; }
}
if (entries.size() != m_manifest.value("files").toArray().size()) { m_offlineError = QCoreApplication::translate("UpdaterLogic", "The number of files in the offline package does not match the manifest."); return false; }
return true;
}
void UpdaterLogic::getDownloadUrl(const QString& appId, const QString& channel, const QString& targetVer, int versionId)
{
QList<FileDownloadItem> signedFiles;
m_fileItems.clear();
if (!m_manifestSignatureVerified
|| !validateOnlineManifest(m_manifest, appId, channel, targetVer, versionId, &signedFiles))
{
qDebug() << "Download URL request rejected because the signed manifest is not valid for the request";
emit fetchUrlFinished();
return;
}
if (signedFiles.isEmpty())
{
qDebug() << "Download URL request rejected because the signed manifest has no files";
emit fetchUrlFinished();
return;
}
QString url = m_serverAddr + "/api/v1/update/download-url";
QJsonObject body;
body["app_id"] = appId;
body["channel"] = channel;
body["version"] = targetVer;
body["version_id"] = versionId;
QJsonArray emptyFiles;
body["files"] = emptyFiles;
m_http.postRequest(url, body, [this, signedFiles](int code, const QJsonObject& resp)
{
qDebug() << "Download URL API returned code:" << code;
m_fileItems.clear();
if (code == 200)
{
const QJsonValue filesValue = resp.value("files");
if (!filesValue.isArray())
{
qDebug() << "Download URL response files field is invalid";
emit fetchUrlFinished();
return;
}
const QJsonArray responseFiles = filesValue.toArray();
if (responseFiles.size() != signedFiles.size())
{
qDebug() << "Download URL response file count does not match the signed manifest";
emit fetchUrlFinished();
return;
}
QMap<QString, int> signedIndexes;
for (int index = 0; index < signedFiles.size(); ++index)
signedIndexes.insert(signedFiles.at(index).path, index);
QList<FileDownloadItem> boundFiles = signedFiles;
QSet<QString> responsePaths;
for (const QJsonValue& value : responseFiles)
{
if (!value.isObject())
{
qDebug() << "Download URL response contains a non-object file item";
emit fetchUrlFinished();
return;
}
const QJsonObject responseFile = value.toObject();
const QJsonValue pathValue = responseFile.value("path");
const QJsonValue urlValue = responseFile.value("url");
const QJsonValue shaValue = responseFile.value("sha256");
const QUrl downloadUrl(urlValue.toString());
qint64 size = -1;
if (!pathValue.isString() || pathValue.toString().isEmpty()
|| !urlValue.isString() || urlValue.toString().trimmed().isEmpty()
|| !downloadUrl.isValid() || downloadUrl.host().isEmpty()
|| (downloadUrl.scheme().compare("http", Qt::CaseInsensitive) != 0
&& downloadUrl.scheme().compare("https", Qt::CaseInsensitive) != 0)
|| !shaValue.isString() || !isSha256(shaValue.toString())
|| !jsonNonNegativeInteger(responseFile.value("size"), &size))
{
qDebug() << "Download URL response contains invalid file metadata";
emit fetchUrlFinished();
return;
}
const QString path = pathValue.toString();
if (responsePaths.contains(path) || !signedIndexes.contains(path))
{
qDebug() << "Download URL response contains a duplicate or extra file:" << path;
emit fetchUrlFinished();
return;
}
responsePaths.insert(path);
const int index = signedIndexes.value(path);
const FileDownloadItem& signedFile = signedFiles.at(index);
if (shaValue.toString().compare(signedFile.sha256, Qt::CaseInsensitive) != 0
|| size != signedFile.size)
{
qDebug() << "Download URL response metadata differs from the signed manifest:" << path;
emit fetchUrlFinished();
return;
}
boundFiles[index].url = urlValue.toString();
}
if (responsePaths.size() != signedFiles.size())
{
qDebug() << "Download URL response is missing signed manifest files";
emit fetchUrlFinished();
return;
}
m_fileItems = boundFiles;
qDebug() << "Bound download URLs to" << m_fileItems.size()
<< "signed manifest files";
}
else
{
qDebug() << "Failed to get download URL";
}
emit fetchUrlFinished();
});
}
QString UpdaterLogic::calcLocalFileSha256(const QString& filePath) const
{
QFile f(filePath);
if (!f.open(QIODevice::ReadOnly))
return "";
QCryptographicHash hash(QCryptographicHash::Sha256);
while (!f.atEnd())
{
QByteArray block = f.read(4096);
hash.addData(block);
}
f.close();
return hash.result().toHex();
}
bool UpdaterLogic::downloadSingleFile(const QString& url, const QString& savePath,
const QString& expectSha256, qint64 expectedSize,
const QString& resumePartPath)
{
const QString partPath = resumePartPath.isEmpty() ? savePath + ".part" : resumePartPath;
if (!QDir().mkpath(QFileInfo(partPath).path())) return false;
if (QFile::exists(savePath)
&& calcLocalFileSha256(savePath).compare(expectSha256, Qt::CaseInsensitive) == 0)
return true;
QFile::remove(savePath);
for (int attempt = 0; attempt < 4; ++attempt)
{
qint64 existingSize = QFileInfo(partPath).size();
if (expectedSize >= 0 && existingSize > expectedSize)
{
QFile::remove(partPath);
existingSize = 0;
}
if (expectedSize >= 0 && existingSize == expectedSize && existingSize > 0)
{
if (calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
{
QFile::remove(savePath);
return QFile::rename(partPath, savePath);
}
QFile::remove(partPath);
existingSize = 0;
}
QFile partFile(partPath);
const QIODevice::OpenMode mode = QIODevice::WriteOnly
| (existingSize > 0 ? QIODevice::Append : QIODevice::Truncate);
if (!partFile.open(mode))
{
qDebug() << "Cannot open partial download:" << partPath;
return false;
}
QNetworkAccessManager manager;
manager.setProxy(QNetworkProxy::NoProxy);
QNetworkRequest request(url);
request.setTransferTimeout(60000);
if (existingSize > 0)
request.setRawHeader("Range", QByteArray("bytes=") + QByteArray::number(existingSize) + "-");
QNetworkReply* reply = manager.get(request);
QEventLoop loop;
bool writeOk = true;
QObject::connect(reply, &QNetworkReply::readyRead, [&]() {
const QByteArray data = reply->readAll();
if (!data.isEmpty()) {
if (partFile.write(data) != data.size()) writeOk = false;
m_sessionDownloadedBytes += data.size();
const qint64 received = qMin(m_downloadTotalBytes,
m_downloadCompletedBytes + partFile.size());
const double speed = m_downloadTimer.elapsed() > 0
? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0;
emit downloadProgress(received, m_downloadTotalBytes, m_currentDownloadPath, speed);
}
});
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
loop.exec();
const QByteArray remaining = reply->readAll();
if (!remaining.isEmpty() && partFile.write(remaining) != remaining.size()) writeOk = false;
partFile.flush();
partFile.close();
const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
const bool networkOk = reply->error() == QNetworkReply::NoError;
const QString networkError = reply->errorString();
reply->deleteLater();
if (existingSize > 0 && httpStatus == 200)
{
// The server ignored Range, so restart instead of appending a full response to the partial file.
qDebug() << "Server ignored Range; restart full download:" << savePath;
QFile::remove(partPath);
}
else if (networkOk && writeOk && (httpStatus == 200 || httpStatus == 206))
{
const qint64 actualSize = QFileInfo(partPath).size();
if ((expectedSize < 0 || actualSize == expectedSize)
&& calcLocalFileSha256(partPath).compare(expectSha256, Qt::CaseInsensitive) == 0)
{
QFile::remove(savePath);
if (QFile::rename(partPath, savePath))
{
qDebug() << "Download completed/resumed & sha pass:" << savePath;
return true;
}
}
else if (expectedSize >= 0 && actualSize >= expectedSize)
{
qDebug() << "Completed partial file has invalid size or SHA; restart:" << savePath;
QFile::remove(partPath);
}
}
else
{
qDebug() << "Download attempt failed; partial file retained:" << attempt + 1
<< savePath << httpStatus << networkError << "bytes" << QFileInfo(partPath).size();
}
if (attempt < 3)
{
const unsigned long delayMs = static_cast<unsigned long>(500 * (1 << attempt));
QElapsedTimer delay;
delay.start();
while (delay.elapsed() < static_cast<qint64>(delayMs))
{
QApplication::processEvents();
QThread::msleep(50);
}
}
}
return false;
}
bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const
{
const QString normalized = QDir::fromNativeSeparators(path).toCaseFolded();
QSet<QString> protectedPaths{
QStringLiteral("bootstrap.exe"),
QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"),
QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"),
QStringLiteral("config/version_policy.dat")
};
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
if (!runtimePrefix.isEmpty()) {
const QStringList runtimeProtected{
QStringLiteral("bootstrap.exe"), QStringLiteral("client.ini"),
QStringLiteral("config/app_config.json"), QStringLiteral("config/local_state.json"),
QStringLiteral("config/client_identity.dat"), QStringLiteral("config/version_policy.dat")
};
for (const QString& protectedPath : runtimeProtected)
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
}
return protectedPaths.contains(normalized);
}
bool UpdaterLogic::isSafeRelativePath(const QString& path) const
{
const QString normalized = QDir::fromNativeSeparators(path);
const QString clean = QDir::cleanPath(normalized);
return !clean.isEmpty()
&& !QDir::isAbsolutePath(clean)
&& clean != ".."
&& !clean.startsWith("../")
&& !clean.contains(":");
}
qint64 UpdaterLogic::estimateAdditionalDiskBytes(const QString& targetDir,
const QStringList& obsoletePaths) const
{
qint64 required = 0;
const QString cacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
for (const auto& item : m_fileItems) {
const QString relativePath = QDir::fromNativeSeparators(item.path);
if (isRuntimeProtectedPath(relativePath)) continue;
const QString installed = QDir(targetDir).filePath(relativePath);
if (QFile::exists(installed)
&& calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) == 0)
continue;
const qint64 partialSize = QFileInfo(QDir(cacheDir).filePath(
item.sha256.toLower() + ".part")).size();
required += qMax<qint64>(0, item.size - qMin(item.size, partialSize));
if (QFile::exists(installed)) required += QFileInfo(installed).size();
}
for (const QString& path : obsoletePaths) {
const QString installed = QDir(targetDir).filePath(path);
if (QFile::exists(installed)) required += QFileInfo(installed).size();
}
const qint64 reserve = qMax<qint64>(128LL * 1024 * 1024, required / 10);
return required + reserve;
}
bool UpdaterLogic::downloadAllFiles(const QString& tempDir, const QString& targetDir)
{
if (m_fileItems.isEmpty())
{
m_downloadAllOk = false;
return false;
}
QDir stagingDir(tempDir);
if (!FileHelper::createDir(tempDir))
return false;
const QString resumeCacheDir = QDir(ConfigHelper::instance().updateRoot()).filePath("download_cache");
if (!FileHelper::createDir(resumeCacheDir))
return false;
QSet<QString> activePartialNames;
for (const auto& item : m_fileItems)
activePartialNames.insert(item.sha256.toLower() + ".part");
QDir cacheDir(resumeCacheDir);
for (const QString& fileName : cacheDir.entryList(QStringList() << "*.part", QDir::Files)) {
if (!activePartialNames.contains(fileName.toLower()))
cacheDir.remove(fileName);
}
m_downloadTotalBytes = 0;
m_downloadCompletedBytes = 0;
m_sessionDownloadedBytes = 0;
for (const auto& item : m_fileItems) {
const QString itemPath = QDir::fromNativeSeparators(item.path);
if (isRuntimeProtectedPath(itemPath)) continue;
const QString installed = QDir(targetDir).filePath(itemPath);
if (!QFile::exists(installed)
|| calcLocalFileSha256(installed).compare(item.sha256, Qt::CaseInsensitive) != 0)
m_downloadTotalBytes += item.size;
}
m_downloadTimer.restart();
emit downloadProgress(0, m_downloadTotalBytes, QString(), 0.0);
for (const auto& fi : m_fileItems)
{
if (!isSafeRelativePath(fi.path))
{
qDebug() << "Unsafe relative path in manifest:" << fi.path;
m_downloadAllOk = false;
return false;
}
const QString relativePath = QDir::fromNativeSeparators(fi.path);
if (isRuntimeProtectedPath(relativePath)) {
qDebug() << "Runtime-protected file skipped:" << relativePath;
continue;
}
const QString installedFile = QDir(targetDir).filePath(relativePath);
if (QFile::exists(installedFile)
&& calcLocalFileSha256(installedFile).compare(fi.sha256, Qt::CaseInsensitive) == 0)
{
qDebug() << "Unchanged file, skip download:" << relativePath;
continue;
}
const QString tempFile = QDir(tempDir).filePath(relativePath);
const QString parentDir = QFileInfo(tempFile).path();
if (!QDir().mkpath(parentDir))
{
qDebug() << "Cannot create staging subdirectory:" << parentDir;
m_downloadAllOk = false;
return false;
}
const QString resumePartPath = QDir(resumeCacheDir).filePath(fi.sha256.toLower() + ".part");
m_currentDownloadPath = relativePath;
const qint64 resumedBytes = qMin(fi.size, QFileInfo(resumePartPath).size());
const double speed = m_downloadTimer.elapsed() > 0
? (m_sessionDownloadedBytes * 1000.0 / m_downloadTimer.elapsed()) : 0.0;
emit downloadProgress(m_downloadCompletedBytes + resumedBytes,
m_downloadTotalBytes, m_currentDownloadPath, speed);
if (!downloadSingleFile(fi.url, tempFile, fi.sha256, fi.size, resumePartPath))
{
m_downloadAllOk = false;
return false;
}
m_downloadCompletedBytes += fi.size;
emit downloadProgress(m_downloadCompletedBytes, m_downloadTotalBytes,
m_currentDownloadPath, speed);
}
m_downloadAllOk = true;
return true;
}
void UpdaterLogic::reportDownloadResult(const QString& appId, const QString& channel,
const QString& version, bool success)
{
QJsonArray files;
for (const FileDownloadItem& item : m_fileItems)
files.append(QJsonObject{{"path", item.path}, {"size", item.size}});
QJsonObject body{{"app_id", appId}, {"channel", channel}, {"version", version},
{"result", success ? "success" : "fail"}, {"files", files}};
m_http.postRequest(m_serverAddr + "/api/v1/update/download-report", body,
[](int code, const QJsonObject&) { qDebug() << "Download result report returned code:" << code; });
}
void UpdaterLogic::reportResult(const QString& deviceId,
const QString& fromVer,
const QString& toVer,
bool success)
{
QString url = m_serverAddr + "/api/v1/update/report";
QJsonObject body;
body["app_id"] = ConfigHelper::instance().getValue("App", "app_id");
body["device_id"] = deviceId;
body["from_version"] = fromVer;
body["to_version"] = toVer;
if (success)
body["result"] = "success";
else
body["result"] = "fail";
m_http.postRequest(url, body, [](int code, const QJsonObject& resp)
{
Q_UNUSED(resp);
qDebug() << "Update result report returned code:" << code;
});
}
QList<FileDownloadItem> UpdaterLogic::getFileList() const
{
return m_fileItems;
}