/* Copyright 2006-2026 The QElectroTech Team This file is part of QElectroTech. QElectroTech is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 2 of the License, or (at your option) any later version. QElectroTech is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with QElectroTech. If not, see . */ #include "logging/qetlogger.h" #include "qetapp.h" #include #include #include #include /** QetLogger::init() asks QETApp for the data directory, and that is the only thing it needs from the application. Standing in for it here is what lets the crash-dump bookkeeping be tested without linking (or starting) the whole of QElectroTech. */ namespace { QString g_data_dir; } QString QETApp::dataDir() { return g_data_dir; } /** @brief The tst_CrashDumps class Covers the crash-dump bookkeeping added in #905: one file per run instead of a single crash_dump.log that each crash overwrote (#898), and the rules about which of those files get offered and deleted. */ class tst_CrashDumps : public QObject { Q_OBJECT private slots: void init(); void listsNothingWhenThereHasBeenNoCrash(); void listsDumpsNewestFirst(); void skipsEmptyDumps(); void excludesThisRunsOwnDump(); void capsTheListAtTenDumps(); void concatenatesEveryOfferedDump(); void clearsOnlyWhatWasOffered(); void redactsTheHomeDirectory(); void redactsAnAppImageMountPoint(); void redactsBothInOneString(); private: QTemporaryDir m_dir; QString crashesDir() const {return g_data_dir + QStringLiteral("/crashes");} void writeDump(const QString &name, const QByteArray &body, const QDateTime &when = QDateTime()); }; void tst_CrashDumps::init() { QVERIFY(m_dir.isValid()); // A fresh subdirectory per test: QetLogger is a singleton, so the // tests share one instance and must not share its files. static int n = 0; g_data_dir = m_dir.path() + QStringLiteral("/run") + QString::number(++n); QVERIFY(QDir().mkpath(g_data_dir)); QVERIFY(QDir().mkpath(crashesDir())); } void tst_CrashDumps::writeDump(const QString &name, const QByteArray &body, const QDateTime &when) { const QString path = crashesDir() + QStringLiteral("/") + name; { QFile f(path); QVERIFY(f.open(QIODevice::WriteOnly)); f.write(body); f.close(); } if (!when.isValid()) { return; } // Separately, and only once the write is closed: setFileTime() needs // an open handle, but closing a file that was just written sets the // modification time to now, which would undo it. QFile stamp(path); QVERIFY(stamp.open(QIODevice::ReadWrite)); QVERIFY(stamp.setFileTime(when, QFileDevice::FileModificationTime)); stamp.close(); } void tst_CrashDumps::listsNothingWhenThereHasBeenNoCrash() { QetLogger::instance().init(); QVERIFY(QetLogger::instance().pendingCrashDumpFiles().isEmpty()); QVERIFY(!QetLogger::instance().hasPendingCrashDump()); } void tst_CrashDumps::listsDumpsNewestFirst() { const QDateTime base = QDateTime::currentDateTime(); writeDump(QStringLiteral("crash_a.log"), "oldest", base.addSecs(-300)); writeDump(QStringLiteral("crash_b.log"), "middle", base.addSecs(-200)); writeDump(QStringLiteral("crash_c.log"), "newest", base.addSecs(-100)); QetLogger::instance().init(); const QStringList files = QetLogger::instance().pendingCrashDumpFiles(); QCOMPARE(files.size(), 3); QVERIFY(files.at(0).endsWith(QStringLiteral("crash_c.log"))); QVERIFY(files.at(1).endsWith(QStringLiteral("crash_b.log"))); QVERIFY(files.at(2).endsWith(QStringLiteral("crash_a.log"))); } /** A zero-length dump means the handler opened the file and died before writing anything. There is nothing to show, and offering an empty report would be worse than offering none. */ void tst_CrashDumps::skipsEmptyDumps() { writeDump(QStringLiteral("crash_empty.log"), QByteArray()); writeDump(QStringLiteral("crash_real.log"), "something"); QetLogger::instance().init(); const QStringList files = QetLogger::instance().pendingCrashDumpFiles(); QCOMPARE(files.size(), 1); QVERIFY(files.at(0).endsWith(QStringLiteral("crash_real.log"))); } /** The dump this run would write if it crashed must never appear in the list of dumps from *previous* runs -- otherwise a process that crashed could be offered its own dump, mid-crash. */ void tst_CrashDumps::excludesThisRunsOwnDump() { writeDump(QStringLiteral("crash_previous.log"), "from a previous run"); QetLogger &logger = QetLogger::instance(); logger.init(); logger.installCrashHandler(); // this is what fixes our own path // installCrashHandler() picked crash__.log for // this process but has not created it -- the handler only writes when // the process actually dies. Standing in for that here: fill in every // name it could have chosen, so whichever one it picked now exists // and is non-empty. The exact second does not have to be guessed. const qint64 pid = QCoreApplication::applicationPid(); const QDateTime now = QDateTime::currentDateTime(); for (int offset = -2 ; offset <= 0 ; ++offset) { writeDump(QStringLiteral("crash_%1_%2.log") .arg(now.addSecs(offset).toString(QStringLiteral("yyyyMMdd-hhmmss"))) .arg(pid), "this run's own dump, mid-crash"); } // Exactly one of those three is the path install() actually chose, // and exactly that one must be missing from the list. The other two // are ordinary files as far as the logger is concerned. const QDir dir(crashesDir()); const int on_disk = dir.entryList({QStringLiteral("crash_*_") + QString::number(pid) + QStringLiteral(".log")}, QDir::Files).size(); QCOMPARE(on_disk, 3); const QStringList files = logger.pendingCrashDumpFiles(); int offered_own = 0; for (const QString &path : files) { if (path.contains(QString::number(pid))) { ++offered_own; } } QCOMPARE(offered_own, 2); // one of the three was excluded QCOMPARE(files.size(), 3); // those two, plus crash_previous.log QVERIFY(files.last().endsWith(QStringLiteral("crash_previous.log"))); } /** A crash loop writes one dump per restart. The list is capped so the dialog cannot be handed an unbounded amount of text. */ void tst_CrashDumps::capsTheListAtTenDumps() { const QDateTime base = QDateTime::currentDateTime(); for (int i = 0 ; i < 15 ; ++i) { writeDump(QStringLiteral("crash_%1.log").arg(i, 2, 10, QChar('0')), QByteArray("dump ") + QByteArray::number(i), base.addSecs(-1000 + i)); } QetLogger::instance().init(); QCOMPARE(QetLogger::instance().pendingCrashDumpFiles().size(), 10); // The ten kept are the newest ten, i.e. 05..14. const QStringList files = QetLogger::instance().pendingCrashDumpFiles(); QVERIFY(files.at(0).endsWith(QStringLiteral("crash_14.log"))); QVERIFY(files.at(9).endsWith(QStringLiteral("crash_05.log"))); } /** #898: every dump is offered, not just the most recent one. The whole point of keeping them is that a repeating crash is where the earlier dumps carry the most information. */ void tst_CrashDumps::concatenatesEveryOfferedDump() { const QDateTime base = QDateTime::currentDateTime(); writeDump(QStringLiteral("crash_one.log"), "FIRST CRASH BODY", base.addSecs(-200)); writeDump(QStringLiteral("crash_two.log"), "SECOND CRASH BODY", base.addSecs(-100)); QetLogger &logger = QetLogger::instance(); logger.init(); const QStringList offered = logger.pendingCrashDumpFiles(); const QByteArray content = logger.pendingCrashDumpContents(offered); QVERIFY(content.contains("FIRST CRASH BODY")); QVERIFY(content.contains("SECOND CRASH BODY")); QVERIFY(content.contains("crash_one.log")); QVERIFY(content.contains("crash_two.log")); QVERIFY(content.contains("2 crash dumps pending")); } /** The reason clearPendingCrashDump() takes the list rather than looking the directory up again: the offer sits in a modal dialog, and a dump that arrives while it is open has never been seen by anybody. */ void tst_CrashDumps::clearsOnlyWhatWasOffered() { writeDump(QStringLiteral("crash_offered.log"), "offered to the user"); QetLogger &logger = QetLogger::instance(); logger.init(); const QStringList offered = logger.pendingCrashDumpFiles(); QCOMPARE(offered.size(), 1); // ... the dialog is open, and a second instance crashes. writeDump(QStringLiteral("crash_arrived_later.log"), "nobody has seen this yet"); logger.clearPendingCrashDump(offered); const QStringList left = logger.pendingCrashDumpFiles(); QCOMPARE(left.size(), 1); QVERIFY(left.at(0).endsWith(QStringLiteral("crash_arrived_later.log"))); } void tst_CrashDumps::redactsTheHomeDirectory() { const QByteArray home = QDir::homePath().toUtf8(); QVERIFY(!home.isEmpty()); const QByteArray in = home + "/projects/secret.qet failed to load"; const QByteArray out = QetLogger::redact(in); QVERIFY(!out.contains(home)); QVERIFY(out.startsWith("~/projects/secret.qet")); } /** backtrace_symbols_fd() writes absolute module paths, which for an AppImage is a per-run mount point under /tmp/.mount_. Raised in review on #905. */ void tst_CrashDumps::redactsAnAppImageMountPoint() { const QByteArray in = "/tmp/.mount_QElect6Yh2Kz/usr/bin/qelectrotech(+0x9b098e) [0x5ecf]\n" "/tmp/.mount_QElect6Yh2Kz/usr/lib/libQt6Core.so.6(+0x1234) [0x7cfa]\n"; const QByteArray out = QetLogger::redact(in); QVERIFY(!out.contains(".mount_QElect6Yh2Kz")); QVERIFY(out.contains("/usr/bin/qelectrotech")); QVERIFY(out.contains("/usr/lib/libQt6Core.so.6")); // The frame offsets are the useful part and must survive. QVERIFY(out.contains("(+0x9b098e) [0x5ecf]")); } void tst_CrashDumps::redactsBothInOneString() { const QByteArray home = QDir::homePath().toUtf8(); const QByteArray in = home + "/Documents/a.qet\n/tmp/.mount_AbCdEf/usr/bin/qet\n"; const QByteArray out = QetLogger::redact(in); QVERIFY(!out.contains(home)); QVERIFY(!out.contains(".mount_AbCdEf")); QVERIFY(out.contains("~/Documents/a.qet")); QVERIFY(out.contains("/usr/bin/qet")); } QTEST_MAIN(tst_CrashDumps) #include "tst_crashdumps.moc"