Files
minecraft-launcher/buildarchiveworker.cpp
T

374 lines
15 KiB
C++
Raw Normal View History

#include "buildarchiveworker.h"
#include "localization.h"
#include <QDir>
#include <QDirIterator>
#include <QFile>
#include <QFileInfo>
2026-08-31 00:59:14 +03:00
#include <algorithm>
#include <QtCore/private/qzipreader_p.h>
#include <QtCore/private/qzipwriter_p.h>
namespace {
// Имена внутри архива приходят из файла и превращаются в пути на диске —
// «../..» увёл бы распаковку за пределы .minecraft. Правило то же, что у
// установщика версий.
bool isSafeRelativePath(const QString &path)
{
if (path.isEmpty() || path.startsWith(u'/') || path.contains(u'\\') || path.contains(u':'))
return false;
const QStringList parts = path.split(u'/');
for (const QString &part : parts) {
if (part == QLatin1String(".") || part == QLatin1String(".."))
return false;
}
return true;
}
2026-08-31 00:59:14 +03:00
// Последняя проверка перед записью: куда бы ни указывало имя из архива после
// всех преобразований, файл обязан оказаться внутри папки игры. Правило имён
// выше это уже гарантирует, но проверка по готовому пути не зависит от того,
// как именно QZipReader разбирает записи.
bool isInside(const QDir &root, const QString &absolutePath)
{
const QString base = QDir::cleanPath(root.absolutePath()) + u'/';
return QDir::cleanPath(absolutePath).startsWith(base);
}
// Верхний сегмент относительного пути: по нему проверяется, не лезет ли пак в
// общие каталоги лаунчера.
QString topSegment(const QString &path)
{
const int slash = path.indexOf(u'/');
return slash < 0 ? path : path.left(slash);
}
// После удаления файлов остаются пустые папки: пустой mods/ вместо удалённого
// набора модов выглядит как незавершённая установка.
void pruneEmptyDirs(const QDir &root, QStringList dirs)
{
// Сначала самые глубокие: удаление вложенной может опустошить родителя.
std::sort(dirs.begin(), dirs.end(),
[](const QString &a, const QString &b) { return a.size() > b.size(); });
for (const QString &relative : std::as_const(dirs)) {
if (relative.isEmpty() || relative == QLatin1String("."))
continue;
QDir dir(root.absoluteFilePath(relative));
if (dir.exists() && dir.isEmpty())
root.rmdir(relative);
}
}
// Файлы, которые попадут в архив, — относительными путями от gameDir.
QStringList collectFiles(const QString &gameDir, const QStringList &excludeTop)
{
QStringList result;
const QDir root(gameDir);
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
| QDir::Hidden | QDir::System);
for (const QFileInfo &entry : top) {
if (excludeTop.contains(entry.fileName()))
continue;
if (entry.isSymLink())
continue; // ссылку в архив не положить, а идти по ней опасно
if (entry.isFile()) {
result << entry.fileName();
continue;
}
if (!entry.isDir())
continue;
QDirIterator it(entry.absoluteFilePath(),
QDir::Files | QDir::Hidden | QDir::System | QDir::NoSymLinks,
QDirIterator::Subdirectories);
while (it.hasNext())
result << root.relativeFilePath(it.next());
}
return result;
}
} // namespace
BuildArchiveWorker::BuildArchiveWorker(QObject *parent)
: QObject(parent)
{
}
void BuildArchiveWorker::archive(const QString &gameDir,
const QString &tempZipPath,
const QStringList &excludeTop)
{
const QDir root(gameDir);
if (!root.exists()) {
emit finished(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(gameDir)));
return;
}
const QStringList files = collectFiles(gameDir, excludeTop);
emit progress(0, files.size(), {});
{
QZipWriter writer(tempZipPath);
if (writer.status() != QZipWriter::NoError) {
emit finished(false,
Loc::text("switch.error.archiveCreateFailed")
.arg(QDir::toNativeSeparators(tempZipPath)));
return;
}
// Миры и конфиги сжимаются хорошо, уже сжатые jar-моды — нет: пусть
// решает эвристика, а не мы.
writer.setCompressionPolicy(QZipWriter::AutoCompress);
int done = 0;
for (const QString &relative : files) {
if (canceled()) {
writer.close();
QFile::remove(tempZipPath);
emit finished(false, Loc::text("switch.status.archivingCancelled"));
return;
}
QFile file(root.absoluteFilePath(relative));
if (!file.open(QIODevice::ReadOnly)) {
// Один нечитаемый файл не повод потерять всю сборку, но и молча
// выкидывать его нельзя — иначе пользователь узнает о пропаже
// уже после очистки .minecraft.
writer.close();
QFile::remove(tempZipPath);
emit finished(false,
Loc::text("common.error.readFailed")
.arg(QDir::toNativeSeparators(relative), file.errorString()));
return;
}
writer.addFile(relative, &file);
file.close();
if (writer.status() != QZipWriter::NoError) {
writer.close();
QFile::remove(tempZipPath);
emit finished(false,
Loc::text("switch.error.archiveWriteFailed")
.arg(QDir::toNativeSeparators(relative)));
return;
}
emit progress(++done, files.size(), relative);
}
writer.close();
}
emit finished(true, {});
}
void BuildArchiveWorker::clear(const QString &gameDir, const QStringList &keepTop)
{
QDir root(gameDir);
if (!root.exists()) {
emit finished(true, {}); // чистить нечего
return;
}
QFileInfoList victims;
const QFileInfoList top = root.entryInfoList(QDir::AllEntries | QDir::NoDotAndDotDot
| QDir::Hidden | QDir::System);
for (const QFileInfo &entry : top) {
if (!keepTop.contains(entry.fileName()))
victims << entry;
}
emit progress(0, victims.size(), {});
int done = 0;
for (const QFileInfo &entry : victims) {
// Отмена посреди очистки оставила бы полупустую папку, которую нечем
// склеить обратно, — на этом шаге её не слушаем.
const bool ok = entry.isDir() && !entry.isSymLink()
? QDir(entry.absoluteFilePath()).removeRecursively()
: QFile::remove(entry.absoluteFilePath());
if (!ok) {
emit finished(false,
Loc::text("switch.error.removeFailed")
.arg(QDir::toNativeSeparators(entry.absoluteFilePath())));
return;
}
emit progress(++done, victims.size(), entry.fileName());
}
emit finished(true, {});
}
void BuildArchiveWorker::restore(const QString &zipPath, const QString &gameDir)
{
QZipReader reader(zipPath);
if (!reader.isReadable()) {
emit finished(false,
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
return;
}
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
emit progress(0, entries.size(), {});
const QDir root(gameDir);
if (!QDir().mkpath(gameDir)) {
emit finished(false,
Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
return;
}
int done = 0;
for (const QZipReader::FileInfo &entry : entries) {
if (canceled()) {
emit finished(false, Loc::text("switch.status.extractCancelled"));
return;
}
if (!entry.isFile) {
emit progress(++done, entries.size(), entry.filePath);
continue;
}
if (!isSafeRelativePath(entry.filePath)) {
emit finished(false,
Loc::text("switch.error.archiveBadPath").arg(entry.filePath));
return;
}
const QString target = root.absoluteFilePath(entry.filePath);
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
emit finished(false,
Loc::text("common.error.createFailed")
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
return;
}
QFile file(target);
const QByteArray data = reader.fileData(entry.filePath);
if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size()) {
emit finished(false,
Loc::text("switch.error.writeFailed")
.arg(QDir::toNativeSeparators(entry.filePath), file.errorString()));
return;
}
file.close();
emit progress(++done, entries.size(), entry.filePath);
}
emit finished(true, {});
}
2026-08-31 00:59:14 +03:00
void BuildArchiveWorker::applyPack(const QString &zipPath,
const QString &gameDir,
const QStringList &removeRelative,
const QStringList &forbiddenTop)
{
QZipReader reader(zipPath);
if (!reader.isReadable()) {
emit finished(false,
Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(zipPath)));
2026-08-31 00:59:14 +03:00
return;
}
// Весь архив проверяется до первой записи на диск: наполовину раскатанный
// пак, отвергнутый на середине, оставил бы сборку в неизвестном состоянии.
const QList<QZipReader::FileInfo> entries = reader.fileInfoList();
QStringList applied;
applied.reserve(entries.size());
for (const QZipReader::FileInfo &entry : entries) {
if (!entry.isFile)
continue;
if (!isSafeRelativePath(entry.filePath)) {
emit finished(false,
Loc::text("switch.error.packBadPath").arg(entry.filePath));
2026-08-31 00:59:14 +03:00
return;
}
if (forbiddenTop.contains(topSegment(entry.filePath))) {
emit finished(false,
Loc::text("switch.error.packTouchesLauncher")
2026-08-31 00:59:14 +03:00
.arg(entry.filePath));
return;
}
applied << entry.filePath;
}
const QDir root(gameDir);
if (!QDir().mkpath(gameDir)) {
emit finished(false, Loc::text("common.error.createFailed").arg(QDir::toNativeSeparators(gameDir)));
2026-08-31 00:59:14 +03:00
return;
}
const int total = removeRelative.size() + applied.size();
emit progress(0, total, {});
int done = 0;
// ── Убираем то, чего в новой ревизии больше нет ──────────────────────
QStringList touchedDirs;
for (const QString &relative : removeRelative) {
if (canceled()) {
emit finished(false, Loc::text("switch.status.installCancelled"));
2026-08-31 00:59:14 +03:00
return;
}
// Список пришёл из нашего же файла состояния, но он лежит на диске
// рядом с игрой и мог быть отредактирован — правила те же, что у архива.
if (!isSafeRelativePath(relative) || forbiddenTop.contains(topSegment(relative))) {
emit progress(++done, total, relative);
continue;
}
// Файл мог быть удалён игроком или прийти заново в новой ревизии —
// и то и другое нормально.
const QString target = root.absoluteFilePath(relative);
if (!isInside(root, target)) {
emit progress(++done, total, relative);
continue;
}
if (QFileInfo::exists(target) && !QFile::remove(target)) {
emit finished(false,
Loc::text("switch.error.removeFailed").arg(QDir::toNativeSeparators(target)));
2026-08-31 00:59:14 +03:00
return;
}
const QString parent = QFileInfo(relative).path();
if (!touchedDirs.contains(parent))
touchedDirs << parent;
emit progress(++done, total, relative);
}
pruneEmptyDirs(root, touchedDirs);
// ── Раскладываем новую ревизию поверх ────────────────────────────────
for (const QString &relative : std::as_const(applied)) {
if (canceled()) {
emit finished(false, Loc::text("switch.status.installCancelled"));
2026-08-31 00:59:14 +03:00
return;
}
const QString target = root.absoluteFilePath(relative);
if (!isInside(root, target)) {
emit finished(false,
Loc::text("switch.error.packEscapesGameDir")
2026-08-31 00:59:14 +03:00
.arg(relative));
return;
}
if (!QDir().mkpath(QFileInfo(target).absolutePath())) {
emit finished(false,
Loc::text("common.error.createFailed")
2026-08-31 00:59:14 +03:00
.arg(QDir::toNativeSeparators(QFileInfo(target).absolutePath())));
return;
}
QFile file(target);
const QByteArray data = reader.fileData(relative);
// QIODevice::Truncate обязателен: поверх более длинного старого файла
// иначе остался бы его хвост.
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)
|| file.write(data) != data.size()) {
emit finished(false,
Loc::text("switch.error.writeFailed")
2026-08-31 00:59:14 +03:00
.arg(QDir::toNativeSeparators(relative), file.errorString()));
return;
}
file.close();
emit progress(++done, total, relative);
}
emit packEntries(applied);
emit finished(true, {});
}