#include "UpdaterLogic.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "ConfigHelper.h" #include "RuntimeProtectionPolicy.h" #ifdef HAVE_OPENSSL #include #include #include #include #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(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 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* 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(versionId)) return false; QSet 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(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()); 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 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 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 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(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(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 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 signedIndexes; for (int index = 0; index < signedFiles.size(); ++index) signedIndexes.insert(signedFiles.at(index).path, index); QList boundFiles = signedFiles; QSet 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(500 * (1 << attempt)); QElapsedTimer delay; delay.start(); while (delay.elapsed() < static_cast(delayMs)) { QApplication::processEvents(); QThread::msleep(50); } } } return false; } bool UpdaterLogic::isRuntimeProtectedPath(const QString& path) const { return UpdateClient::isRuntimeProtectedPath( path, ConfigHelper::instance().runtimeRelativePath()); } 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(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(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 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 UpdaterLogic::getFileList() const { return m_fileItems; }