#include "buildarchiveworker.h" #include #include #include #include #include #include #include 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; } // Последняя проверка перед записью: куда бы ни указывало имя из архива после // всех преобразований, файл обязан оказаться внутри папки игры. Правило имён // выше это уже гарантирует, но проверка по готовому пути не зависит от того, // как именно 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, tr("Папка игры не найдена: %1").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, tr("Не удалось создать архив %1") .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, tr("Архивация отменена")); return; } QFile file(root.absoluteFilePath(relative)); if (!file.open(QIODevice::ReadOnly)) { // Один нечитаемый файл не повод потерять всю сборку, но и молча // выкидывать его нельзя — иначе пользователь узнает о пропаже // уже после очистки .minecraft. writer.close(); QFile::remove(tempZipPath); emit finished(false, tr("Не удалось прочитать %1: %2") .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, tr("Ошибка записи архива на файле %1") .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, tr("Не удалось удалить %1") .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, tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath))); return; } const QList entries = reader.fileInfoList(); emit progress(0, entries.size(), {}); const QDir root(gameDir); if (!QDir().mkpath(gameDir)) { emit finished(false, tr("Не удалось создать %1").arg(QDir::toNativeSeparators(gameDir))); return; } int done = 0; for (const QZipReader::FileInfo &entry : entries) { if (canceled()) { emit finished(false, tr("Распаковка отменена")); return; } if (!entry.isFile) { emit progress(++done, entries.size(), entry.filePath); continue; } if (!isSafeRelativePath(entry.filePath)) { emit finished(false, tr("Архив повреждён: недопустимый путь «%1»").arg(entry.filePath)); return; } const QString target = root.absoluteFilePath(entry.filePath); if (!QDir().mkpath(QFileInfo(target).absolutePath())) { emit finished(false, tr("Не удалось создать %1") .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, tr("Не удалось записать %1: %2") .arg(QDir::toNativeSeparators(entry.filePath), file.errorString())); return; } file.close(); emit progress(++done, entries.size(), entry.filePath); } emit finished(true, {}); } void BuildArchiveWorker::applyPack(const QString &zipPath, const QString &gameDir, const QStringList &removeRelative, const QStringList &forbiddenTop) { QZipReader reader(zipPath); if (!reader.isReadable()) { emit finished(false, tr("Не удалось открыть архив %1").arg(QDir::toNativeSeparators(zipPath))); return; } // Весь архив проверяется до первой записи на диск: наполовину раскатанный // пак, отвергнутый на середине, оставил бы сборку в неизвестном состоянии. const QList 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, tr("Архив сборки повреждён: недопустимый путь «%1»").arg(entry.filePath)); return; } if (forbiddenTop.contains(topSegment(entry.filePath))) { emit finished(false, tr("Архив сборки пытается изменить общие файлы лаунчера: «%1»") .arg(entry.filePath)); return; } applied << entry.filePath; } const QDir root(gameDir); if (!QDir().mkpath(gameDir)) { emit finished(false, tr("Не удалось создать %1").arg(QDir::toNativeSeparators(gameDir))); 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, tr("Установка сборки отменена")); 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, tr("Не удалось удалить %1").arg(QDir::toNativeSeparators(target))); 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, tr("Установка сборки отменена")); return; } const QString target = root.absoluteFilePath(relative); if (!isInside(root, target)) { emit finished(false, tr("Архив сборки повреждён: путь «%1» ведёт за пределы папки игры") .arg(relative)); return; } if (!QDir().mkpath(QFileInfo(target).absolutePath())) { emit finished(false, tr("Не удалось создать %1") .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, tr("Не удалось записать %1: %2") .arg(QDir::toNativeSeparators(relative), file.errorString())); return; } file.close(); emit progress(++done, total, relative); } emit packEntries(applied); emit finished(true, {}); }