feat(client): 迁移Hub更新客户端实现
This commit is contained in:
+115
-54
@@ -1,52 +1,62 @@
|
||||
#include "IntegrityHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include "IntegrityHelper.h"
|
||||
#include "ConfigHelper.h"
|
||||
#include "UpdatePathPolicy.h"
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QCoreApplication>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSet>
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
|
||||
IntegrityHelper::IntegrityHelper(const QString& installDir)
|
||||
#ifdef HAVE_OPENSSL
|
||||
#include <openssl/evp.h>
|
||||
#include <openssl/pem.h>
|
||||
#endif
|
||||
|
||||
namespace {
|
||||
|
||||
QString configValue(const QString& key, const QString& fallback = QString())
|
||||
{
|
||||
const QString value = ConfigHelper::instance().getValue(QString(), key).trimmed();
|
||||
return value.isEmpty() ? fallback : value;
|
||||
}
|
||||
|
||||
bool configFlag(const QString& key)
|
||||
{
|
||||
const QString value = configValue(key).toLower();
|
||||
return value == QStringLiteral("true")
|
||||
|| value == QStringLiteral("1")
|
||||
|| value == QStringLiteral("yes")
|
||||
|| value == QStringLiteral("on");
|
||||
}
|
||||
|
||||
bool manifestFileRequired(const QJsonObject& item)
|
||||
{
|
||||
if (!item.contains(QStringLiteral("required")))
|
||||
return true;
|
||||
return item.value(QStringLiteral("required")).toBool(true);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
IntegrityHelper::IntegrityHelper(const QString& installDir)
|
||||
: m_installDir(QDir::cleanPath(installDir)) {}
|
||||
|
||||
QString IntegrityHelper::errorString() const { return m_error; }
|
||||
|
||||
bool IntegrityHelper::safeRelativePath(const QString& path) const
|
||||
{
|
||||
const QString clean = QDir::cleanPath(QDir::fromNativeSeparators(path));
|
||||
return !clean.isEmpty() && !QDir::isAbsolutePath(clean) && clean != ".."
|
||||
&& !clean.startsWith("../") && !clean.contains(":");
|
||||
}
|
||||
|
||||
bool IntegrityHelper::safeRelativePath(const QString& path) const
|
||||
{
|
||||
return UpdatePathPolicy::isSafeRelativePath(path);
|
||||
}
|
||||
|
||||
bool IntegrityHelper::runtimeProtectedPath(const QString& path) const
|
||||
{
|
||||
// 这些文件属于 SDK 运行态,不参与业务版本文件的 Manifest 校验。
|
||||
// 例如 app_config.json、client_identity.dat 会随安装机器变化,不能要求它们和发布包 hash 完全一致。
|
||||
const QString p = QDir::fromNativeSeparators(path).toCaseFolded();
|
||||
QSet<QString> protectedPaths{
|
||||
"bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
||||
"config/client_identity.dat", "config/version_policy.dat"
|
||||
};
|
||||
const QString runtimePrefix = ConfigHelper::instance().runtimeRelativePath().toCaseFolded();
|
||||
if (!runtimePrefix.isEmpty()) {
|
||||
const QStringList runtimeProtected{
|
||||
"bootstrap", "bootstrap.exe", "client.ini", "config/app_config.json", "config/local_state.json",
|
||||
"config/client_identity.dat", "config/version_policy.dat"
|
||||
};
|
||||
for (const QString& protectedPath : runtimeProtected)
|
||||
protectedPaths.insert(runtimePrefix + "/" + protectedPath);
|
||||
}
|
||||
return protectedPaths.contains(p);
|
||||
}
|
||||
return UpdatePathPolicy::isFullUpdateProtectedPath(
|
||||
path, ConfigHelper::instance().runtimeRelativePath());
|
||||
}
|
||||
|
||||
QString IntegrityHelper::sha256(const QString& filePath) const
|
||||
{
|
||||
@@ -127,16 +137,39 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
.arg(version, cachePath, wrapperError.errorString());
|
||||
return false;
|
||||
}
|
||||
const QJsonObject wrapper = wrapperDoc.object();
|
||||
const QByteArray manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
||||
const QString signature = wrapper.value("manifest").toObject().value("signature").toString();
|
||||
if (manifestText.isEmpty() || signature.isEmpty()) {
|
||||
const QJsonObject wrapper = wrapperDoc.object();
|
||||
QByteArray manifestText = wrapper.value("manifestText").toString().toUtf8();
|
||||
if (manifestText.isEmpty())
|
||||
manifestText = wrapper.value("manifest_text").toString().toUtf8();
|
||||
const QString manifestSha256 = wrapper.value("manifestSha256").toString(
|
||||
wrapper.value("manifest_sha256").toString());
|
||||
const QString signature = wrapper.value("signature").toString(
|
||||
wrapper.value("manifest").toObject().value("signature").toString());
|
||||
const bool signedManifest = wrapper.value("signed").toBool(!signature.isEmpty());
|
||||
if (manifestText.isEmpty()) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local signed manifest cache is incomplete. Stage: installed version verification. Version: %1. File: %2.")
|
||||
.arg(version, cachePath);
|
||||
return false;
|
||||
}
|
||||
if (!verifySignature(manifestText, signature)) return false;
|
||||
if (!manifestSha256.isEmpty()) {
|
||||
const QString actualSha = QString::fromLatin1(
|
||||
QCryptographicHash::hash(manifestText, QCryptographicHash::Sha256).toHex());
|
||||
if (actualSha.compare(manifestSha256, Qt::CaseInsensitive) != 0) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local manifest SHA-256 does not match the cached envelope. Stage: installed version verification. Version: %1.\nExpected SHA-256: %2\nActual SHA-256: %3")
|
||||
.arg(version, manifestSha256, actualSha);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (signedManifest && !signature.isEmpty()) {
|
||||
if (!verifySignature(manifestText, signature)) return false;
|
||||
} else if (configFlag(QStringLiteral("require_manifest_signature"))) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local manifest cache is unsigned, but require_manifest_signature is enabled. Stage: installed version verification. Version: %1.")
|
||||
.arg(version);
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonParseError manifestError;
|
||||
const QJsonDocument manifestDoc = QJsonDocument::fromJson(manifestText, &manifestError);
|
||||
@@ -147,20 +180,23 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
return false;
|
||||
}
|
||||
const QJsonObject manifest = manifestDoc.object();
|
||||
if (manifest.value("app_id").toString() != appId
|
||||
const QString manifestProduct = manifest.value("productCode").toString(
|
||||
manifest.value("app_id").toString());
|
||||
if (manifestProduct != appId
|
||||
|| manifest.value("channel").toString() != channel
|
||||
|| manifest.value("version").toString() != version) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected app/channel/version: %1 / %2 / %3. Manifest app/channel/version: %4 / %5 / %6.")
|
||||
"Local signed manifest identity does not match this application. Stage: installed version verification. Expected product/channel/version: %1 / %2 / %3. Manifest product/channel/version: %4 / %5 / %6.")
|
||||
.arg(appId, channel, version,
|
||||
manifest.value("app_id").toString(),
|
||||
manifestProduct,
|
||||
manifest.value("channel").toString(),
|
||||
manifest.value("version").toString());
|
||||
return false;
|
||||
}
|
||||
|
||||
QSet<QString> declaredExecutables;
|
||||
for (const QJsonValue& value : manifest.value("files").toArray()) {
|
||||
QSet<QString> declaredExecutables;
|
||||
QSet<QString> optionalComponentDirs;
|
||||
for (const QJsonValue& value : manifest.value("files").toArray()) {
|
||||
const QJsonObject item = value.toObject();
|
||||
const QString path = QDir::fromNativeSeparators(item.value("path").toString());
|
||||
if (!safeRelativePath(path)) {
|
||||
@@ -169,6 +205,14 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
.arg(version, path);
|
||||
return false;
|
||||
}
|
||||
if (UpdatePathPolicy::isExecutableOrLibrary(path))
|
||||
declaredExecutables.insert(path.toCaseFolded());
|
||||
if (!manifestFileRequired(item)) {
|
||||
const QString dir = QDir::fromNativeSeparators(QFileInfo(path).path());
|
||||
if (!dir.isEmpty() && dir != QStringLiteral("."))
|
||||
optionalComponentDirs.insert((dir + QStringLiteral("/")).toCaseFolded());
|
||||
continue;
|
||||
}
|
||||
if (runtimeProtectedPath(path)) continue;
|
||||
const QString fullPath = QDir(m_installDir).filePath(path);
|
||||
if (!QFile::exists(fullPath)) {
|
||||
@@ -177,6 +221,17 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
.arg(version, path, fullPath);
|
||||
return false;
|
||||
}
|
||||
const qint64 expectedSize = item.contains("sizeBytes")
|
||||
? item.value("sizeBytes").toVariant().toLongLong()
|
||||
: item.value("size").toVariant().toLongLong();
|
||||
if ((item.contains("sizeBytes") || item.contains("size"))
|
||||
&& QFileInfo(fullPath).size() != expectedSize) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"Installed file size does not match the local manifest. Stage: installed version verification. Version: %1. Manifest path: %2. Local path: %3.\nExpected size: %4 bytes\nActual size: %5 bytes")
|
||||
.arg(version, path, fullPath,
|
||||
QString::number(expectedSize), QString::number(QFileInfo(fullPath).size()));
|
||||
return false;
|
||||
}
|
||||
const QString expected = item.value("sha256").toString();
|
||||
const QString actual = sha256(fullPath);
|
||||
if (actual.isEmpty() || actual.compare(expected, Qt::CaseInsensitive) != 0) {
|
||||
@@ -186,9 +241,7 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
actual.isEmpty() ? QCoreApplication::translate("IntegrityHelper", "<cannot read file>") : actual);
|
||||
return false;
|
||||
}
|
||||
const QString suffix = QFileInfo(path).suffix().toCaseFolded();
|
||||
if (suffix == "exe" || suffix == "dll") declaredExecutables.insert(path.toCaseFolded());
|
||||
}
|
||||
}
|
||||
|
||||
QDir root(m_installDir);
|
||||
QDirIterator it(m_installDir, QDir::Files, QDirIterator::Subdirectories);
|
||||
@@ -202,10 +255,18 @@ bool IntegrityHelper::verifyInstalledVersion(const QString& appId, const QString
|
||||
const bool runtimeWorkDir = !runtimePrefix.isEmpty()
|
||||
&& (folded.startsWith(runtimePrefix + "/update/")
|
||||
|| folded.startsWith(runtimePrefix + "/update_temp/"));
|
||||
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|
||||
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
|
||||
const QString suffix = QFileInfo(relative).suffix().toCaseFolded();
|
||||
if ((suffix == "exe" || suffix == "dll") && !declaredExecutables.contains(folded)) {
|
||||
if (folded.startsWith("update/") || folded.startsWith("update_temp/")
|
||||
|| runtimeWorkDir || runtimeProtectedPath(relative)) continue;
|
||||
bool optionalComponentFile = false;
|
||||
for (const QString& prefix : optionalComponentDirs) {
|
||||
if (folded.startsWith(prefix)) {
|
||||
optionalComponentFile = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (optionalComponentFile)
|
||||
continue;
|
||||
if (UpdatePathPolicy::isExecutableOrLibrary(relative) && !declaredExecutables.contains(folded)) {
|
||||
m_error = QCoreApplication::translate("IntegrityHelper",
|
||||
"An executable or DLL exists locally but is not declared in the signed manifest. Stage: installed version verification. Version: %1. Extra file: %2. Remove unexpected executable/plugin files or publish a new version that declares them.")
|
||||
.arg(version, relative);
|
||||
|
||||
Reference in New Issue
Block a user