#include "testenv.h" #include "fixtures.h" #include "buildarchiveworker.h" #include "localization.h" #include #include class TestBuildArchiveWorker : public QObject { Q_OBJECT QString m_game; QString m_work; void populateGame() { Fixtures::write(m_game + "/options.txt", "fov:90"); Fixtures::write(m_game + "/mods/a.jar", "mod-a"); Fixtures::write(m_game + "/mods/sub/b.jar", "mod-b"); Fixtures::write(m_game + "/config/x/y.toml", "y=1"); Fixtures::write(m_game + "/saves/World/level.dat", "level"); // общее — исключается Fixtures::write(m_game + "/versions/1.20.1/1.20.1.json", "{}"); // общее — исключается } // Последний вызов finished(): (ok, error) static QPair result(const QSignalSpy &spy) { if (spy.isEmpty()) return {false, QStringLiteral("finished не пришёл")}; return {spy.constLast().at(0).toBool(), spy.constLast().at(1).toString()}; } private slots: void init() { m_game = TestEnv::freshDir(QStringLiteral("game")); m_work = TestEnv::freshDir(QStringLiteral("archives")); } void archiveClearRestoreRoundTrip() { populateGame(); const QStringList shared{"saves", "versions"}; const QString zip = m_work + "/build.zip"; BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); QSignalSpy progress(&worker, &BuildArchiveWorker::progress); worker.archive(m_game, zip, shared); QCOMPARE(result(finished), qMakePair(true, QString())); QCOMPARE(Fixtures::zipEntries(zip), QStringList({"config/x/y.toml", "mods/a.jar", "mods/sub/b.jar", "options.txt"})); // progress: (0,total) в начале и (total,total) в конце. QCOMPARE(progress.constFirst().at(0).toInt(), 0); QCOMPARE(progress.constLast().at(0).toInt(), 4); QCOMPARE(progress.constLast().at(1).toInt(), 4); worker.clear(m_game, shared); QCOMPARE(result(finished), qMakePair(true, QString())); QCOMPARE(Fixtures::listTree(m_game), QStringList({"saves/World/level.dat", "versions/1.20.1/1.20.1.json"})); worker.restore(zip, m_game); QCOMPARE(result(finished), qMakePair(true, QString())); QCOMPARE(Fixtures::listTree(m_game), QStringList({"config/x/y.toml", "mods/a.jar", "mods/sub/b.jar", "options.txt", "saves/World/level.dat", "versions/1.20.1/1.20.1.json"})); QCOMPARE(Fixtures::readFile(m_game + "/mods/sub/b.jar"), QByteArray("mod-b")); } void dotFilesSurviveRoundTrip() { // В .minecraft живут .fabric/, .mixin.out/, на macOS ещё .DS_Store. Fixtures::write(m_game + "/.fabric/remappedJars/x.jar", "cache"); Fixtures::write(m_game + "/mods/a.jar", "a"); const QString zip = m_work + "/dots.zip"; BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); worker.archive(m_game, zip, {}); QVERIFY(result(finished).first); worker.clear(m_game, {}); worker.restore(zip, m_game); QVERIFY(result(finished).first); QVERIFY(QFile::exists(m_game + "/mods/a.jar")); // QZipReader при чтении срезает ведущие «.» и «/» в именах записей, поэтому // после переключения сборок .fabric/ возвращается как fabric/. QEXPECT_FAIL("", "QZipReader срезает ведущую точку: .fabric/ восстанавливается как fabric/", Continue); QVERIFY(QFile::exists(m_game + "/.fabric/remappedJars/x.jar")); } void archiveMissingGameDir() { BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); const QString missing = m_game + "/nope"; worker.archive(missing, m_work + "/x.zip", {}); QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.error.gameDirMissing").arg(QDir::toNativeSeparators(missing)))); } void archiveCancelRemovesTempZip() { populateGame(); BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); worker.requestCancel(); worker.archive(m_game, m_work + "/cancel.zip", {}); QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.archivingCancelled"))); QVERIFY(!QFile::exists(m_work + "/cancel.zip")); worker.clearCancel(); worker.archive(m_game, m_work + "/ok.zip", {}); QVERIFY(result(finished).first); } void clearMissingDirIsOk() { BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); worker.clear(m_game + "/nope", {}); QVERIFY(result(finished).first); } void restoreRejectsUnreadableAndUnsafeArchives() { BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); const QString missing = m_work + "/missing.zip"; worker.restore(missing, m_game); QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.error.archiveOpenFailed").arg(QDir::toNativeSeparators(missing)))); // Zip-slip: запись с «..» не должна вылезти из папки игры. const QString evil = m_work + "/evil.zip"; // Ведущие «../» QZipReader срезает сам, а «..» в середине пути — нет. QVERIFY(Fixtures::makeZip(evil, {{"ok.txt", "ok"}, {"mods/../../escaped.txt", "pwned"}})); QVERIFY(Fixtures::zipEntries(evil).contains("mods/../../escaped.txt")); worker.restore(evil, m_game); QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.error.archiveBadPath").arg("mods/../../escaped.txt"))); QVERIFY(!QFile::exists(QFileInfo(m_game).absolutePath() + "/escaped.txt")); } void restoreCancel() { const QString zip = m_work + "/r.zip"; QVERIFY(Fixtures::makeZip(zip, {{"a.txt", "a"}})); BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); worker.requestCancel(); worker.restore(zip, m_game); QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.extractCancelled"))); } void applyPackRemovesThenWrites() { populateGame(); Fixtures::write(m_game + "/mods/old/legacy.jar", "old"); Fixtures::write(m_game + "/mods/keep.jar", "keep"); const QString pack = m_work + "/pack.zip"; QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"}, {"config/x/y.toml", "y=2"}, {"options.txt", "fov:70"}})); BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); QSignalSpy entries(&worker, &BuildArchiveWorker::packEntries); worker.applyPack(pack, m_game, {"mods/old/legacy.jar", "mods/missing.jar", "../outside.txt", "versions/1.20.1/1.20.1.json"}, {"versions", "saves"}); QCOMPARE(result(finished), qMakePair(true, QString())); QVERIFY(!QFile::exists(m_game + "/mods/old/legacy.jar")); QVERIFY(!QDir(m_game + "/mods/old").exists()); // опустевшая папка убрана QVERIFY(QFile::exists(m_game + "/mods/keep.jar")); // Удаление в запретной зоне молча пропускается. QVERIFY(QFile::exists(m_game + "/versions/1.20.1/1.20.1.json")); QCOMPARE(Fixtures::readFile(m_game + "/mods/new.jar"), QByteArray("new")); QCOMPARE(Fixtures::readFile(m_game + "/config/x/y.toml"), QByteArray("y=2")); QCOMPARE(Fixtures::readFile(m_game + "/options.txt"), QByteArray("fov:70")); QCOMPARE(entries.count(), 1); QStringList applied = entries.constFirst().constFirst().toStringList(); applied.sort(); QCOMPARE(applied, QStringList({"config/x/y.toml", "mods/new.jar", "options.txt"})); } void applyPackRefusesLauncherFolders() { populateGame(); const QString pack = m_work + "/bad.zip"; QVERIFY(Fixtures::makeZip(pack, {{"mods/new.jar", "new"}, {"versions/hack/hack.json", "{}"}})); BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); worker.applyPack(pack, m_game, {"mods/a.jar"}, {"versions"}); QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.error.packTouchesLauncher").arg("versions/hack/hack.json"))); // Проверка до любых изменений: ничего не удалено и не записано. QVERIFY(QFile::exists(m_game + "/mods/a.jar")); QVERIFY(!QFile::exists(m_game + "/mods/new.jar")); } void applyPackRefusesUnsafePaths() { const QString pack = m_work + "/slip.zip"; QVERIFY(Fixtures::makeZip(pack, {{"mods/../../../etc/x", "x"}})); BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); worker.applyPack(pack, m_game, {}, {}); QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.error.packBadPath").arg("mods/../../../etc/x"))); } void applyPackCancel() { const QString pack = m_work + "/c.zip"; QVERIFY(Fixtures::makeZip(pack, {{"a.txt", "a"}})); BuildArchiveWorker worker; QSignalSpy finished(&worker, &BuildArchiveWorker::finished); worker.requestCancel(); worker.applyPack(pack, m_game, {}, {}); QCOMPARE(result(finished), qMakePair(false, Loc::text("switch.status.installCancelled"))); QVERIFY(!QFile::exists(m_game + "/a.txt")); } }; BILAUNCHER_TEST_MAIN(TestBuildArchiveWorker) #include "tst_buildarchiveworker.moc"